/* MatchPlan.ai — router, app shell (sidebar), theme, page header. */

// ---- tiny hash router ----
function useRoute() {
  const [hash, setHash] = React.useState(window.location.hash || "#/login");
  React.useEffect(() => {
    const on = () => setHash(window.location.hash || "#/login");
    window.addEventListener("hashchange", on);
    return () => window.removeEventListener("hashchange", on);
  }, []);
  // Strip any ?query before route matching — pages that need the query read it
  // straight off window.location.hash. Without this, a hash like "/briefs?x=1"
  // yields a segment "briefs?x=1" that matches no route, so the page never changes.
  return hash.replace(/^#/, "").split("?")[0] || "/login";
}
function navigate(to) { window.location.hash = to; window.scrollTo(0, 0); }
function matchRoute(path) {
  const parts = path.split("/").filter(Boolean);
  return {
    is: (pattern) => {
      const pp = pattern.split("/").filter(Boolean);
      if (pp.length !== parts.length) return false;
      const params = {};
      for (let i = 0; i < pp.length; i++) {
        if (pp[i].startsWith(":")) params[pp[i].slice(1)] = parts[i];
        else if (pp[i] !== parts[i]) return false;
      }
      return params;
    }
  };
}

// ---- shared theme state (kept in sync with Tweaks) ----

// The Edge sidebar destinations (#79, wiki spec `LiTidOIzud`): workspace
// dropdown (rendered by the shell), Home, Briefs, Setup. "New brief", "Team",
// "Refer & earn" and the old billing item are removed as standalone items — their
// ROUTES stay reachable (app.jsx). /upgrade in particular is load-bearing: it is
// where the shell's "Add credits" lands when no Suite page can be built, and
// where old links arrive (pages-billing.jsx, #286). Brief creation survives via
// the plus beside Briefs and the page-level actions on Home and Briefs.
// "Setup" keeps the /settings path on purpose — the spec renames the label and
// the page title, not the route.
const NAV_ITEMS = [
  { to: "/dashboard", label: "Home", icon: "House" },
  { to: "/briefs", label: "Briefs", icon: "FolderOpen" },
  { to: "/settings", label: "Setup", icon: "Settings" }
];

// The MOBILE bottom bar's destinations (#114). A SECOND list, hoisted here from
// inside MobileBottom so it is reachable by the same guard as NAV_ITEMS.
//
// WHY THIS EXISTS AS ITS OWN LIST: the bar is not a narrow sidebar. It carries
// "You" rather than "Setup" and its own icon set, so it is not derivable from
// NAV_ITEMS by filtering — the two genuinely differ. What they must NOT differ
// on is the spec's removals.
//
// #79 applied those removals to NAV_ITEMS only, and "New brief" survived here
// until #114. `.mp-bottombar` is display:none by default and display:flex under
// `@media (max-width: 1023px)` (kit.css:598/601), so this list is what every
// phone and most tablets actually render — the leftover was live, not dead.
const MOBILE_NAV_ITEMS = [
  { to: "/dashboard", label: "Home", icon: "House" },
  { to: "/briefs", label: "Briefs", icon: "FolderOpen" },
  { to: "/settings", label: "You", icon: "User" }
];

// EVERY navigation list in Edge, in one place, so the spec's removals can be
// asserted across all of them rather than against whichever array a reader
// happened to find. #114 existed because #79's guard named one list; the test
// pins this registry's SIZE, so adding a third list without adding it here
// fails rather than opening a third silent gap.
const NAV_LISTS = { NAV_ITEMS, MOBILE_NAV_ITEMS };

// ============================================================================
// Entitlement "meters" — shared surface for the dashboard and the credits page.
// ONE bucket: the credits the platform reported (packBalance), which expire
// 12 months out. There is no monthly allowance — design doc
// docs/superpowers/specs/2026-09-22-edge-credits-platform-only-design.md Part 3
// removed the local cap − usage arithmetic, since no platform grants one.
// The credits page's numbers come straight off the store (hydrated from /me).
// The dashboard's do NOT (#315): /me's packBalance is the workspace pool, which
// a monthly limit can put out of reach, so Home shows the sidebar's own
// limit-aware number instead (useCachedShellContext below).
// Exported on window so pages-dash.jsx (which I also own) can reuse them.
// ============================================================================

// How a balance the platform could NOT report is shown (design doc §1.3): the
// server sends `packBalance: null`, and it must never render as "0 credits".
// `value` is the glyph the Suite shell's own sidebar uses for the same unknown
// (evolve-shell `creditsLineParts` → "—"), so the two surfaces agree.
const CREDITS_UNKNOWN = { value: "—", text: "Credits unavailable right now" };

// Exact expiry date, e.g. "Jul 1, 2026" — used for the "expiring soon" nudge.
// Credit-lot expiries are real calendar dates; render in UTC for stability.
function fmtExpiryDate(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "";
  return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
}

// Whole days until an ISO instant (rounded up; never negative). Drives copy
// like "expires in 12 days". 0 ⇒ today/overdue (caller says "soon").
function daysUntil(iso) {
  const d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  return Math.max(0, Math.ceil((d.getTime() - Date.now()) / 86400000));
}

// Live store subscription — re-renders on any `mp-state` change so the meter
// reflects credits/usage the instant a brief is generated or a pack is bought.
function useStoreTick() {
  const [, force] = React.useReducer((n) => n + 1, 0);
  React.useEffect(() => {
    window.addEventListener("mp-state", force);
    return () => window.removeEventListener("mp-state", force);
  }, []);
}

// ---- Suite shell context (pitchiq #70) ----
// GET /api/shell for window.EvolveShell.EvolveShell (replaces the old
// Sidebar). Fetches once on mount and re-fetches on "mp-state" — the same
// event a workspace switch dispatches (and the same event useStoreTick and,
// through it, the Shell listen to) — so a
// workspace switch elsewhere in the app can't strand the shell showing the
// prior workspace's name/credits/role (coldreview M4).
// Module-level cache so a REMOUNTING Shell (every page wraps its own <Shell>,
// so each navigation mounts a fresh one) renders instantly from the last
// context instead of hiding itself for a network round trip — the
// vanish-and-reappear #70 QA follow-up. Cleared on sign-out (no token) so the
// nav-ready gate still protects the truly-unauthenticated/failed-first-load
// cases; a transient refetch failure keeps the stale context on purpose.
let __shellCtxCache = null;
// Bumped by every workspace switch. An /api/shell answer requested before a switch describes the
// PREVIOUS workspace, so it is dropped rather than cached or shown (see resetShellContext).
let __shellCtxEpoch = 0;
// #315 review R1-#003 — answers can land out of order: every `mp-state` starts a read, and a brief's
// burn fires several close together. Each read is numbered, and an answer applies only if it is newer
// than the last one APPLIED: a pre-burn answer landing late never replaces the post-burn one, while a
// failed newest read still lets an older successful one through.
let __shellCtxSeq = 0;
let __shellCtxAppliedSeq = 0;
// evolve-suite#1343 — the /api/shell reads still IN FLIGHT. Each settles either way (it never rejects) and takes itself
// out when it does. What a workspace switch that stays in Edge waits on behind its loader (see shellContextSettled).
const __shellCtxReads = new Set();

// #269 (review R1-#002) — forget the context on a workspace switch, and tell every mounted hook.
// The Add-credits destination is chosen from this context's workspace KIND, so a context kept from
// the previous workspace sends money to the previous workspace's pot. "A transient refetch failure
// keeps the stale context" (above) is safe only while the workspace is the same one. After a
// switch, a failed refetch leaves NO context — no sidebar, no destination — until one succeeds:
// fail closed. Called by PSA.switchWorkspace (app/api.js), which both switchers go through.
function resetShellContext() {
  __shellCtxCache = null;
  __shellCtxEpoch++;
  try { window.dispatchEvent(new Event("mp-shell-reset")); } catch (e) {}
}

// #315 — writes a load's result to the cache and announces it, so a page that shows the context
// without fetching it (useCachedShellContext) holds what its <Shell> holds. The switch reset above
// announces its own clearing, with `mp-shell-reset`.
function cacheShellContext(data) {
  __shellCtxCache = data;
  try { window.dispatchEvent(new Event("mp-shell-context")); } catch (e) {}
}

function useShellContext() {
  const [ctx, setCtx] = React.useState(__shellCtxCache);
  React.useEffect(() => {
    let alive = true;
    const load = () => {
      if (!window.PSA || !PSA.enabled || !MP.isReturning()) {
        // Signed out is NEWER than any read still in flight: store.js logout() notifies before it clears
        // the user, so one more read starts, and its late answer must not refill the cache for the next
        // sign-in (the page does not reload).
        __shellCtxAppliedSeq = ++__shellCtxSeq;
        cacheShellContext(null); if (alive) setCtx(null); return;
      }
      const epoch = __shellCtxEpoch;
      const seq = ++__shellCtxSeq;
      const read = PSA.shell().then((data) => {
        if (epoch !== __shellCtxEpoch || seq <= __shellCtxAppliedSeq) return;
        __shellCtxAppliedSeq = seq;
        cacheShellContext(data);
        if (alive) setCtx(data);
      }).catch(() => {}).finally(() => { __shellCtxReads.delete(read); });
      __shellCtxReads.add(read);
    };
    const reset = () => { if (alive) setCtx(null); load(); };
    load();
    window.addEventListener("mp-state", load);
    window.addEventListener("mp-shell-reset", reset);
    return () => {
      alive = false;
      window.removeEventListener("mp-state", load);
      window.removeEventListener("mp-shell-reset", reset);
    };
  }, []);
  return ctx;
}

// evolve-suite#1343 — for a workspace switch that STAYS in Edge, shown behind the Switching Loader. The switch drops the
// context (resetShellContext), and a Shell draws no rail and no sidebar until an /api/shell read gives it one. This
// resolves when NO /api/shell read is in flight: every read started before or during the wait has answered or failed.
// It looks again after each batch, because reads keep starting meanwhile: every `mp-state` starts one, and so does
// every Shell that mounts (the switch's own re-reads and its landing on Home do both).
// What it does NOT promise: a context. Reads that all FAIL leave none (fail closed, above), and then there is nothing
// more to wait for, so the loader comes down on a page without chrome rather than staying up. Nor can it see a read
// that has not started: a Shell that has rendered but whose mount effect has not run yet.
// It never rejects, and a failed read never hangs it.
async function shellContextSettled() {
  while (__shellCtxReads.size > 0) await Promise.all([...__shellCtxReads]);
}

// #315 — the shell context for a page that SHOWS it without fetching it: what the page's own <Shell>
// last loaded, then every later answer and every switch reset. It never calls PSA.shell(), so Home
// showing the sidebar's credit number costs no second /api/shell request (each one reads the
// platform). Only for a page rendered inside a <Shell> — that Shell's useShellContext does the loading.
function useCachedShellContext() {
  const [ctx, setCtx] = React.useState(__shellCtxCache);
  React.useEffect(() => {
    const sync = () => setCtx(__shellCtxCache);
    // Catch up first: the Shell's effect runs before this one, and can clear the cache (signed out)
    // before this page is listening.
    sync();
    window.addEventListener("mp-shell-context", sync);
    window.addEventListener("mp-shell-reset", sync);
    return () => {
      window.removeEventListener("mp-shell-context", sync);
      window.removeEventListener("mp-shell-reset", sync);
    };
  }, []);
  return ctx;
}

// Rail/app-tile glyph (`ShellApp.icon` — required to render anything, package
// ships none). No Evolve Edge/Coach product mark exists yet, so both tiles
// reuse the Evolve Suite diamond mark — same glyph, same reasoning as Coach's
// own EvolveAppTileIcon (matchiq-for-sellers app-shell.tsx).
//
// #1215 — now the shell's own EvolveMark (Figma 695:45206, a 24x24 icon slot),
// the same component the Suite rail's own "Suite" tile renders at size 24, off
// the global build (window.EvolveShell.EvolveMark — this file has no bundler-
// level import of the package, same as every other shell component it uses).
// This used to be a hand-drawn flat diamond copied from that mark's geometry
// rather than rendering it, which is why it had already drifted out of sync
// once elsewhere in this estate (see EvolveMark's own docblock in the shell
// package). Called only from the same site `window.EvolveShell.EvolveShell`
// itself is used unconditionally, so `window.EvolveShell` is already known
// present here.
function EvolveAppTileIcon() {
  return <window.EvolveShell.EvolveMark size={24} />;
}

// ---- Suite shell (desktop, ≥1024px — the package hides itself below that
// via its own CSS) ----
// `ctx` is passed down from Shell (not fetched here) so Shell can know
// whether the shell actually has something to render — see Shell's own
// comment for why that matters (review §9: a permanently-failed fetch must
// not leave <main> with a blank 328px gutter and no nav).
// ⚠️ COMPOSE, NEVER CONCATENATE, ONTO A VALUE FROM `/api/shell`.
//
// `ctx.suiteHref` is NOT the bare Suite URL. `server/src/routes/shell.ts:272` builds it with
// `withWorkspaceHint`, so it ALREADY CARRIES `?wsid=<id>` whenever the account has a platform
// workspace id — which, per that file's own docblock, is the common case for self-serve accounts
// now, not an edge case.
//
// So `suiteHref + "/settings/..."` puts the path INSIDE THE QUERY STRING. Measured by running it:
//
//   ctx.suiteHref  https://dev.app.evolvesuite.io/?wsid=org_abc123
//   concatenated   https://dev.app.evolvesuite.io/?wsid=org_abc123/settings/billing/add-credits
//   pathname       "/"                                         <- the Suite HOME, not the page
//   wsid           "org_abc123/settings/billing/add-credits"   <- and the hint is destroyed
//
// Both failures from one `+`: the destination is lost AND the workspace hint is corrupted, so the
// Suite cannot even honour the workspace the user was in.
//
// ⚠️ `shell.ts:113` says this corruption "cannot" happen — and that is TRUE OF THAT FUNCTION,
// which joins via `URL`. It is not true of a caller that concatenates onto its output. A guarantee
// documented without its scope reads as a global one.
//
// The path is appended to the EXISTING pathname rather than replacing it, so a Suite deployed
// under a base path keeps it — the same thing the old `replace(/\/+$/, "") + path` intended.
function suiteHrefWithPath(shellHref, path) {
  if (!shellHref) return null;
  try {
    const url = new URL(String(shellHref));
    url.pathname = url.pathname.replace(/\/+$/, "") + path;
    return url.toString();
  } catch {
    // A non-absolute or malformed configured URL — there is no query string to protect, so fall
    // back to the previous behaviour rather than dropping the link entirely.
    return String(shellHref).replace(/\/+$/, "") + path;
  }
}

// Edge's app root, and the value a cross-product `returnUrl` must point at.
//
// ⛔ NOT THE BARE ORIGIN: "/" only 302s here (server/src/app.ts, pitchiq#387), and that extra
// hop is one more place for a return path to lose its way.
// ⛔ AND HASHLESS: index.html sets `__mpFreshArrival = !window.location.hash`, which is what
// app.jsx keys Edge's SSO bootstrap on for the default /login screen. A screen hash is added to
// this base only through `edgeReturnScreenHash` below, which refuses the screens it would break.
//
// ⚠️ STATED DEBT (pitchiq#277): this is the THIRD copy of the literal — `suite-redirect.js:57`
// RETURN_PATH and `pages-auth.jsx:43` inline are the others. Deliberately NOT consolidated:
// `suite-redirect.js` is a plain pre-React script loaded by index.html with no module boundary to
// import across, and #277's scope is this file. Recorded so the next reader finds the decision
// rather than re-deriving it.
const EDGE_RETURN_PATH = "/app";

// #285 R1-#002 — shown when the re-read after an accepted workspace switch fails. The store was
// already emptied of the workspace left (fail closed), so without this the page would state "No
// briefs yet" / "Briefs generated 0" as fact about a workspace it simply could not load.
const WORKSPACE_RELOAD_FAILED_TEXT = "Couldn't load this workspace — reload to try again.";
// Exported here rather than in the list at the bottom, so this change and #211's edits to that list
// stay apart.
Object.assign(window, { WORKSPACE_RELOAD_FAILED_TEXT });

// evolve-suite#1331 — the screens a cross-product return may name: the signed-in pages app.jsx
// routes to, and nothing else.
//
// ⛔ AN ALLOW-LIST, NOT A DENY-LIST. The return URL travels to the Suite in a query string and sits
// in a Suite cookie, so whatever is in the hash goes with it. The invite email links to
// `#/invite?token=<secret>`, which app.jsx has no route for, so a signed-in visitor sees the
// Dashboard with the token still in the address bar: a deny-list would have sent that token on.
// The hash's own query is dropped for the same reason; no page reads one.
//
// Every screen here attempts Edge's sign-in start-up with a hash too (app.jsx `needsSsoAttempt`
// needs a HASHLESS arrival only on /login), which is what already lets a deep link survive the
// Suite sign-in round trip (pitchiq#352).
const EDGE_RETURN_SCREENS = ["/dashboard", "/briefs", "/prepare/new", "/settings", "/upgrade"];
// `/prepare/:id` and `/post-call/:id`, with an id made of URL-safe characters only.
const EDGE_RETURN_ITEM_SCREEN = /^\/(prepare|post-call)\/[A-Za-z0-9_-]+$/;

// The hash to add to a return URL so the Suite can send the visitor back to the screen they are on,
// or "" for the plain app root when the hash names no screen from the list above.
function edgeReturnScreenHash(hash) {
  if (typeof hash !== "string" || !hash.startsWith("#/")) return "";
  const screen = hash.slice(1).split("?")[0];
  return EDGE_RETURN_SCREENS.includes(screen) || EDGE_RETURN_ITEM_SCREEN.test(screen) ? "#" + screen : "";
}

// Attach the originating-app parameters the Suite needs to offer a route home (pitchiq#277,
// evolve-suite#996).
//
// The Suite's `readOriginatingApp` (apps/suite/src/lib/originating-app.ts:187) returns null unless
// BOTH `app` and `returnUrl` are present, and validates the origin against its own allow-list,
// `EVOLVE_ALLOWED_RETURN_ORIGINS` — an EXACT origin match that fails closed when unset. So this is
// a hint the Suite may refuse, never a redirect it is obliged to follow. Same wire shape Edge
// already emits at pages-auth.jsx:42 and suite-redirect.js:88.
//
// ⛔ NO ORIGIN ⇒ NO PARAMETERS, and that is not a cosmetic blank. A bare "/app" returnUrl is
// RELATIVE, so the Suite's validator admits it as same-origin BY CONSTRUCTION and would send the
// visitor to the SUITE's own /app — a wrong destination that passes validation silently.
// `suite-redirect.js:82` refuses to open a door it cannot build a return path through; this
// refuses the same thing, while leaving the link itself working.
//
// Composed via `URL`, so an existing `?wsid=` hint survives — see the concatenation warning above.
//
// `screenHash` (evolve-suite#1331) is {@link edgeReturnScreenHash}'s answer: the screen to come
// back to. Omitted, the return is the hashless app root, as the account link has always sent.
function withEdgeReturn(href, edgeOrigin, screenHash = "") {
  if (!href || !edgeOrigin) return href;
  try {
    const url = new URL(String(href));
    url.searchParams.set("app", "edge");
    url.searchParams.set("returnUrl", edgeOrigin + EDGE_RETURN_PATH + screenHash);
    return url.toString();
  } catch {
    // A non-absolute or malformed configured Suite URL. `suiteHrefWithPath` already degraded to
    // concatenation for this case; appending a query to that is guesswork, so emit it untouched.
    return href;
  }
}

// evolve-suite#1331 — the Suite's Create Workspace wizard, its "the intro was already shown" marker
// (evolve-suite `CREATE_WORKSPACE_INTRO_SEEN_PARAM`/`_VALUE`, apps/suite/src/lib/settings-menu.ts)
// and the intro's picture, a Suite public asset (`CREATE_WORKSPACE_INTRO_IMAGE`, same file).
const SUITE_CREATE_WORKSPACE_PATH = "/create-workspace";
const SUITE_CREATE_WORKSPACE_INTRO_SEEN_PARAM = "intro";
const SUITE_CREATE_WORKSPACE_INTRO_SEEN_VALUE = "seen";
const SUITE_CREATE_WORKSPACE_INTRO_IMAGE_PATH = "/create-workspace-intro.png";

// Where the intro's "Get started" goes: the wizard, told the intro was seen so it does not show it
// again. Composed via `URL`, so the `?wsid=` hint on `suiteHref` survives. Null with no Suite URL.
function suiteCreateWorkspaceStartHref(suiteHref) {
  const wizard = suiteHrefWithPath(suiteHref, SUITE_CREATE_WORKSPACE_PATH);
  if (!wizard) return null;
  try {
    const url = new URL(wizard);
    url.searchParams.set(SUITE_CREATE_WORKSPACE_INTRO_SEEN_PARAM, SUITE_CREATE_WORKSPACE_INTRO_SEEN_VALUE);
    return url.toString();
  } catch {
    // A non-absolute or malformed configured Suite URL: `suiteHrefWithPath` already degraded to
    // concatenation, and appending a query to that is guesswork — the plain wizard link still works.
    return wizard;
  }
}

// The intro picture's ABSOLUTE Suite URL (a relative one would 404 on Edge's origin), without the
// workspace hint an asset has no use for. Null with no Suite URL, or one that is not absolute:
// the shell then keeps today's behaviour (the row goes straight to the Suite).
function suiteCreateWorkspaceIntroImageSrc(suiteHref) {
  const image = suiteHrefWithPath(suiteHref, SUITE_CREATE_WORKSPACE_INTRO_IMAGE_PATH);
  if (!image) return null;
  try {
    const url = new URL(image);
    url.search = "";
    return url.toString();
  } catch {
    return null;
  }
}

// The Suite's two add-credits pages: one per credit pot.
const SUITE_ACCOUNT_ADD_CREDITS_PATH = "/settings/billing/add-credits";
const SUITE_WORKSPACE_ADD_CREDITS_PATH = "/workspace-settings/billing/add-credits";

// #269 — where "Add credits" goes: the Suite page that sells into the pot of the workspace the
// user is IN. Shared by the sidebar (ShellNav below) and the billing page (pages-billing.jsx), so
// the two cannot drift. Follows Coach's resolver (matchiq-for-sellers
// `services/frontend/src/lib/add-credits-destination.ts`), checked 2026-09-21:
//
//   - personal -> the ACCOUNT page. Spec `vS6s3So5dJ` (Account Settings > Billing, rev 22).
//   - team     -> the WORKSPACE page. Spec `3XDQoF0G2E` (Credits Billing Section, rev 20) L145:
//                 "Opens the Billing / Add credits page for the active personal or team
//                 workspace". The account page here was the defect: a team's purchase aimed at
//                 the person's personal pot.
//   - unknown  -> null. The platform could not be read, and BOTH pages are wrong then.
//   - no SUITE_URL -> null (matchiq-for-sellers#828: prod has none), never "null/settings/...".
//
// No role logic here. Who may BUY is the caller's gate, not this function's.
function suiteAddCreditsHref(ctx) {
  if (!ctx) return null;
  if (ctx.workspace.kind === "personal") return suiteHrefWithPath(ctx.suiteHref, SUITE_ACCOUNT_ADD_CREDITS_PATH);
  if (ctx.workspace.kind === "team") return suiteHrefWithPath(ctx.suiteHref, SUITE_WORKSPACE_ADD_CREDITS_PATH);
  return null;
}

// The Suite's Adjust-limit pages (evolve-suite `app/settings/billing/adjust-limit`, account;
// `app/(workspace)/workspace-settings/billing/adjust-limit`, workspace) and its Members area.
const SUITE_ACCOUNT_ADJUST_LIMIT_PATH = "/settings/billing/adjust-limit";
const SUITE_WORKSPACE_ADJUST_LIMIT_PATH = "/workspace-settings/billing/adjust-limit";
const SUITE_MEMBERS_PATH = "/members";
// `/members?manage=<userRef>` opens Manage Member for that person on arrival — evolve-suite
// `MEMBERS_MANAGE_PARAM` (apps/suite/src/lib/settings-menu.ts:41), matched against the roster's `userRef`.
const SUITE_MEMBERS_MANAGE_PARAM = "manage";

// matchiq-for-sellers#1500 — Members with Manage Member already open for the VIEWER (spec `9j0ags32tR` rev 8
// L282). `ctx.user.userRef` is their platform id (/api/shell); with none (an unlinked user) there is nobody to
// name, so it is plain Members. Composed via `URL`, so the `?wsid=` the Members link carries survives.
function suiteManageMemberHref(ctx) {
  const members = suiteHrefWithPath(ctx.suiteHref, SUITE_MEMBERS_PATH);
  const userRef = ctx.user.userRef;
  if (!members || typeof userRef !== "string" || userRef === "") return members;
  try {
    const url = new URL(members);
    url.searchParams.set(SUITE_MEMBERS_MANAGE_PARAM, userRef);
    return url.toString();
  } catch {
    // A non-absolute or malformed configured Suite URL: `suiteHrefWithPath` already degraded to concatenation,
    // and appending a query to that is guesswork — the plain Members link still works.
    return members;
  }
}

// pitchiq#211 / #229 — where a Credit Availability dialog's action goes (spec `9j0ags32tR` rev 8),
// for the workspace the user is IN. Each is null when no correct page exists: no context, no
// SUITE_URL, or a page that does not apply to this workspace kind. web/app/credit-refusal.js picks
// one per scenario; like `suiteAddCreditsHref`, this carries no role logic.
//
//   - addCredits           → `suiteAddCreditsHref` above, unchanged (#269).
//   - adjustAccountLimit   → Account Settings > Billing > Adjust limit (personal).
//   - adjustWorkspaceLimit → Workspace Settings > Billing > Adjust limit (team). Consumes `?wsid=`.
//   - members              → the Members area (team): a GROUP limit. ⚠️ CLOSEST PAGE, NOT THE SPEC'S —
//     the spec opens Members > Groups with Manage Group open for their group, and the Suite has no
//     deep link for that yet (evolve-suite#1039), so the admin opens the group themselves.
//   - manageMember         → Members with Manage Member open for the viewer: an INDIVIDUAL limit, the
//     spec's own destination (#1500, `suiteManageMemberHref`). Plain Members when the viewer has no
//     platform ref.
//   Both carry `?wsid=`, which /members consumes first — it moves the session into the affected
//   workspace and keeps `?manage=` (evolve-suite `members/page.tsx:92`, `consumeWorkspaceHint`,
//   checked at b7939950, 2026-09-23).
function suiteCreditDestinations(ctx) {
  const kind = ctx ? ctx.workspace.kind : null;
  return {
    addCredits: suiteAddCreditsHref(ctx),
    adjustAccountLimit: kind === "personal" ? suiteHrefWithPath(ctx.suiteHref, SUITE_ACCOUNT_ADJUST_LIMIT_PATH) : null,
    adjustWorkspaceLimit: kind === "team" ? suiteHrefWithPath(ctx.suiteHref, SUITE_WORKSPACE_ADJUST_LIMIT_PATH) : null,
    members: kind === "team" ? suiteHrefWithPath(ctx.suiteHref, SUITE_MEMBERS_PATH) : null,
    manageMember: kind === "team" ? suiteManageMemberHref(ctx) : null,
  };
}

function ShellNav({ path, ctx, onOpenSupport, inSetup }) {
  // Before the early return: hooks run on every render. ShellNav sits inside ToastProvider (app.jsx).
  const toast = useToast();
  // Loading / signed-out / fetch-failed / vendor script not loaded: render
  // nothing. <main> stays full-width and usable (Shell only applies the
  // margin-left offset once `ctx` has actually resolved) — never a crash,
  // and never a dead gutter either.
  if (!ctx || !window.EvolveShell) return null;

  // Suite tool-bar entry (#79, spec `bcOAsMTFDQ` Tool-Bar Visibility table): shown to
  // roles that can reach Suite administration, hidden for personal workspaces
  // and team Members. Edge's role vocabulary has no `app_manager` (see
  // server/src/routes/shell.ts) — that column of the spec's table is
  // unreachable here, so it is deliberately not represented.
  const showSuiteEntry = ctx.workspace.kind === "team"
    && (ctx.role === "owner" || ctx.role === "workspace_admin");

  // Built once per render rather than inside the handler, so the null case is decided where a
  // reader can see it rather than at click time.
  //
  // #277 — the link carries `app=edge` + `returnUrl` so the Suite's "Close settings" can send the
  // visitor back HERE instead of stranding them on the Suite (evolve-suite#996). Trailing slashes
  // stripped the same way suite-redirect.js:85 strips them, so a browser reporting an origin with
  // one cannot produce "//app". `withEdgeReturn` no-ops when the origin is unknown.
  const edgeOrigin = ((window.location && window.location.origin) || "").replace(/\/+$/, "");
  const suiteAccountHref = withEdgeReturn(
    suiteHrefWithPath(ctx.suiteHref, "/settings/account"),
    edgeOrigin
  );

  // #1045 — "Add credits" belongs to the Suite, not to Edge. Marcos, 2026-09-02:
  // "that shouldn't even exist anymore, all the credits should be handled at
  // evolve suite level now." CITED TO HIM DELIBERATELY: the credits spec
  // (`3XDQoF0G2E` rev 18) is SILENT on Edge — it says where the component lives
  // (the suite sidebar), never that Edge must not ship a billing page. His call
  // is the authority here; the spec does not carry it.
  //
  // #269 — THE PAGE FOR THE WORKSPACE THE USER IS IN. This used to send everyone to the ACCOUNT
  // page, reasoning that a team Member would otherwise reach the workspace page with its action
  // hidden. Both halves of that were checked on 2026-09-21 and the conclusion does not follow:
  //
  //   - The account page sells into the PERSONAL pot. A team owner pressing it on dev sent a
  //     purchase aimed at their personal workspace while Edge showed the team's balance
  //     (pitchiq#269). That is money in the wrong pot, not a safe fallback.
  //   - A Member never sees this button. The shell gates it itself: `canManageBilling` is
  //     owner/workspace_admin for a team, and the card renders no "Add credits" otherwise —
  //     measured against the served build in web/test/shell-credits-role-gate.test.js. That is
  //     the spec's rule (`3XDQoF0G2E` rev 20 L129-L132: "the 'Add credits' action should be
  //     hidden"), and it is Coach's behaviour too (its billing page shows a Member an access
  //     notice, never a link).
  //
  // The workspace hint is carried: `suiteHref` already holds `?wsid=` (see suiteHrefWithPath).
  // No Suite destination (no SUITE_URL, or an unknown kind) falls back to /upgrade below.
  const suiteCreditsHref = suiteAddCreditsHref(ctx);

  // #1092 — the workspace menu's "Create workspace" row. Edge, like Coach, never passed the
  // handler, so the shared shell rendered no row and there was NO route to workspace creation
  // from either product. Marcos, 2026-09-03: "that menu item needs to be available, otherwise
  // there is no way to create it."
  //
  // AUTHORITY, spec AND Figma. Spec `bcOAsMTFDQ` (Suite Navigation, updatedAt
  // 2026-09-01T20:57:46Z) L70 groups the personal workspace, team workspaces AND
  // "Create workspace" in THE workspace menu — the shared shell's, not the Suite's alone;
  // L111 gives the on-click. Figma `fYvRupTfIiOEBXYGOcvZZZ` node `4:3735`
  // ("Workspace menu — Open") shows the row in a PRODUCT shell: that frame's rail carries
  // `App: Coach` and `App: Edge` while its `Suite` entry is hidden="true".
  //
  // NO ROLE GATE, and that is measured rather than assumed: the same document gates this
  // row's NEIGHBOURS explicitly (Suite entry L49 + role table L56, app-enabling L87/L227,
  // request-access L210) and gates this one by nothing. 18 gating lines in that document
  // served as the control, so the silence is a real absence, not a failed search. In
  // particular this is deliberately NOT gated on `showSuiteEntry` — that is the Suite
  // entry's role rule, and reusing it here would invent a restriction the spec does not
  // carry and hide the row from every Member.
  //
  // Same null guard and the same `suiteHrefWithPath` as the two destinations above: an
  // unset SUITE_URL yields null, the handler is then omitted entirely, and the shell's own
  // `onCreate &&` gate leaves the row unrendered rather than pointing it at
  // "null/create-workspace". Using the helper (not concatenation) is load-bearing —
  // `ctx.suiteHref` carries `?wsid=`, so `href + path` would bury the path in the query
  // string and land on the Suite HOME with the hint destroyed, which is the bug the helper
  // was written for.
  //
  // evolve-suite#1331 — this is now where GET STARTED goes, not the row click: it carries the
  // Suite's intro-seen marker so the wizard opens on Step 1 instead of showing the intro again.
  //
  // The handler below adds `app=edge` + `returnUrl`, like the account link above, so Step 1's
  // Cancel in the Suite can come back (spec rev 77 L110, L199).
  const suiteCreateWorkspaceHref = suiteCreateWorkspaceStartHref(ctx.suiteHref);
  // evolve-suite#1331 — the intro's picture. With it, the shell opens the intro over this page.
  const suiteCreateWorkspaceIntroImage = suiteCreateWorkspaceIntroImageSrc(ctx.suiteHref);

  const active = (to) => path === to || (to === "/dashboard" && (path === "/" || path === "/dashboard"));
  // pitchiq#208 — Edge Navigation "Navigation states: Incomplete setup" (`LiTidOIzud`): while
  // onboarding is in progress, Home/Briefs/Setup use `State=Skeleton` and stay noninteractive,
  // with no Briefs creation action. ONE flag, not per-phase: the spec applies it "throughout
  // Start, Analyzing, Refine, and Review, including during loading, validation, and errors" —
  // OnboardingPage's phase switch lives entirely below ShellNav, so there is no phase in which
  // `inSetup` should read differently. evolve-shell's own NavItem (0.21.2) renders a non-default
  // state as a noninteractive <div> and never reads `item.action` for it, so omitting
  // onSelect/active/action here is enough — no extra stripping needed.
  const navItems = inSetup
    ? NAV_ITEMS.map((it) => ({ key: it.to, label: it.label, icon: <Icon name={it.icon} size={19} />, state: "skeleton" }))
    : NAV_ITEMS.map((it) => ({
      key: it.to,
      label: it.label,
      icon: <Icon name={it.icon} size={19} />,
      onSelect: () => navigate(it.to),
      active: active(it.to),
      // The spec's navigation-level entry point for creating a brief (#79).
      // It replaces the removed standalone "New brief" item, so it is required,
      // not decorative. The shell owns its hover treatment; Edge supplies the
      // glyph from its own existing icon set.
      ...(it.to === "/briefs"
        ? { action: { icon: <Icon name="Plus" size={16} />, label: "New brief", onSelect: () => navigate("/prepare/new") } }
        : {}),
    }));
  const apps = ctx.apps.map((a) => ({ ...a, icon: <EvolveAppTileIcon /> }));

  return React.createElement(window.EvolveShell.EvolveShell, {
    user: ctx.user,
    workspace: ctx.workspace,
    workspaces: ctx.workspaces,
    role: ctx.role,
    apps,
    navItems,
    credits: ctx.credits,
    // matchiq-for-sellers#1442 — the platform-resolved sidebar number (`null` = no answer, drawn as "—"),
    // relayed untouched from /api/shell. The shell shows it instead of `credits`; Edge computes nothing.
    compactAvailability: ctx.compactAvailability,
    showSuiteEntry,
    suiteHref: ctx.suiteHref,
    // evolve-suite#1302 (evolve-shell 0.24.1) — the rail's Suite tile: the shell calls this for a plain left click or Enter
    // (after preventDefault) and leaves modified / middle clicks to the browser. The Suite's own Product loader, "Opening
    // Evolve Suite...". No Suite URL, no entry, so no handler.
    // evolve-suite#1312 - behind the loader it opens the Suite's entry door, which opens Overview once and carries this
    // loader on from how long it has been up. `elapsedMs` is absent when no loader was shown (no host mounted, or the
    // shell has no Suite colour, so `productSpec` gave no loader): then there is nothing to carry on, and it opens the
    // SAME href the tile carries, as before.
    ...(ctx.suiteHref
      ? {
          onSelectSuite: () => window.SwitchLoader.enter(
            window.SwitchLoader.productSpec(window.EvolveShell, { key: "suite", name: "Evolve Suite" }),
            (elapsedMs) => window.location.assign(
              elapsedMs === undefined
                ? ctx.suiteHref
                : window.SwitchLoader.withElapsed(window.SwitchLoader.suiteEntryUrl(ctx.suiteHref), elapsedMs),
            ),
          ),
        }
      : {}),
    // pitchiq#208 — Edge Navigation "Incomplete setup": the credits billing section stays hidden
    // throughout onboarding. EvolveShell's own composition (0.21.2) already does this —
    // `footer: inSetup ? void 0 : <CreditsSection .../>` — so Edge only needs to relay the flag.
    inSetup,
    onSwitchWorkspace: async (id) => {
      // #1338 — the workspace is the PLATFORM's fact. This used to write localStorage and stop,
      // which is why Edge and the rest of the Suite could sit on different workspaces
      // indefinitely. The write-through is what makes a correction here STICK: it moves the real
      // pointer, so there is no second copy left to go stale.
      //
      // FAILS CLOSED, deliberately. On a refusal or an outage the route answers 403/502 and this
      // throws, so we do NOT bank the id and do NOT navigate — reporting success on an outage
      // would leave the browser reloading into the OLD workspace while believing it switched.
      // `switchWorkspace` has already decided whether the client may keep a copy.
      //
      // pitchiq#428 — a workspace Edge is not enabled for (the personal one) is refused with the Suite's switch table
      // (#362). The switch itself succeeded and Edge just is not the destination, so that refusal takes the SAME
      // workspace loader as an accepted switch, then leaves for that URL; api.js used to leave from inside the
      // request, before any loader could be shown. A target the context does not list has no loader to wait behind, so
      // api.js still leaves at once for it. Any other failure returns, as before.
      const target = ctx.workspaces.find((candidate) => candidate.id === id);
      // Logged up front, once, whatever the switch answers: an unlisted target never gets a loader, and a refusal or a
      // local-only stay would otherwise leave no trace of why.
      if (target === undefined) console.error("[switch-loader] reason=switch_loader_workspace_not_listed: no loader for this target");
      let switched;
      let refusedTo = null;
      try {
        switched = await PSA.switchWorkspace(id, target !== undefined);
      } catch (e) {
        if (!e.switchDestinationUrl) return;
        refusedTo = e.switchDestinationUrl;
      }
      // pitchiq#319 — where the switch lands is the Suite's switch table (Suite Navigation `bcOAsMTFDQ`
      // rev 55 L86-101): an Owner/Admin of a team goes to Overview, anyone else keeps Edge if they may
      // use it there. A full load, so none of the in-app re-reading below is needed. `null` (no Suite,
      // or a local-only switch) keeps today's behaviour.
      const tableUrl = refusedTo ?? await PSA.suiteSwitchDestinationUrl(switched);
      if (tableUrl) {
        // evolve-suite#1302 (flow 13) — the WORKSPACE loader, "Opening [workspace name]...". The Suite's door decides where
        // this lands AFTER this navigation and Edge cannot ask it first, so a Product loader would name an app the door may
        // not choose; the workspace being opened is true whatever it answers. The artwork is the row's photo
        // when the context carries a `photoUrl` (#1384, optional), else the building placeholder. A target the context does not list has no name to show: it leaves at once.
        // evolve-suite#1311 - both hand-offs land on the Suite door, which continues the loader's tile from how long it has been up.
        // `elapsedMs` is absent when no loader was shown (unlisted target, no host), and then the URL is left as it is.
        const go = (elapsedMs) => {
          const doorUrl = window.SwitchLoader.withElapsed(tableUrl, elapsedMs);
          if (refusedTo) window.location.replace(doorUrl);
          else window.location.assign(doorUrl);
        };
        if (target === undefined) {
          go();
        } else {
          window.SwitchLoader.enter(window.SwitchLoader.workspaceSpec(target), go);
        }
        return;
      }
      // evolve-suite#1343 — no Suite door (a local-only switch, or no Suite configured), so the switch stays in Edge. It is
      // the same switch, so it takes the same WORKSPACE loader (Suite Navigation `bcOAsMTFDQ` rev 85 L112: "After the user
      // selects a workspace, show **Workspace with photo** or **Workspace without photo** ... with "Opening [workspace
      // name]..."."). There is no navigation to wait behind, so the loader is held AROUND the in-app re-read: the shell
      // takes it down once this has resolved and its minimum is up. This resolves only when no /api/shell read is in
      // flight any more (shellContextSettled), so the loader stays up while the rail and sidebar are being read for the
      // new workspace. If those reads fail there is no chrome to wait for, and it comes down on a page without them
      // (fail closed, #269). A target the context does not list has no name to show: no loader, as above.
      const reloaded = (async () => {
        // #285 — the new workspace's /me AND briefs list: re-reading /me alone updated the counters
        // while the list kept the old workspace's briefs. A failed read leaves nothing of the old one.
        try { await MP.reloadWorkspace(); } catch (e) { toast.error(WORKSPACE_RELOAD_FAILED_TEXT); }
        window.dispatchEvent(new Event("mp-state"));
        // Account-scoped pages don't all listen to mp-state — land on home so
        // everything remounts under the new account (final-review N1).
        navigate("/");
        await shellContextSettled();
      })();
      if (target !== undefined) window.SwitchLoader.hold(window.SwitchLoader.workspaceSpec(target), reloaded);
      await reloaded;
    },
    // pitchiq#257 — follows an enabled app; opens add / enable / request IN PLACE for a not-enabled one.
    onSelectApp: (app) => window.selectMoreApp(app),
    // Omitted, not null, when the Suite is unconfigured — the shell keys the row's existence
    // on the handler's presence (`onCreate &&`), so passing a no-op would render a dead row.
    ...(suiteCreateWorkspaceHref
      ? {
          // The screen is read at CLICK time: Edge's screens change without this shell re-rendering.
          onCreateWorkspace: () => {
            window.location.href = withEdgeReturn(
              suiteCreateWorkspaceHref,
              edgeOrigin,
              edgeReturnScreenHash(window.location.hash)
            );
          },
        }
      : {}),
    // evolve-suite#1331 — spec `eqb7VfpiM2` rev 76 L119: "the current page should remain visible
    // behind an introductory dialog." Given this prop the shell opens the intro IN PLACE and calls
    // `onCreateWorkspace` only on Get started; Close calls nothing, so the user stays on this page.
    ...(suiteCreateWorkspaceIntroImage
      ? { createWorkspaceIntro: { imageSrc: suiteCreateWorkspaceIntroImage } }
      : {}),
    // No Suite destination (no SUITE_URL, or an unknown workspace kind): Edge's own /upgrade page.
    // It sells nothing — its "Buy more briefs" card shows no button in exactly these cases
    // (pages-billing.jsx `BuyCreditsCard`) — so no pot can be charged from there (#269).
    // A handler is always passed, because omitting it does NOT hide the button: for an unknown
    // kind the shell's `canManageBilling` falls to `role === "owner"`, and the card renders
    // "Add credits" whenever that holds (evolve-shell `canManageBilling` in the credits section —
    // cited by symbol, not dist line numbers, which moved between 0.16.0 and 0.17.1).
    // Measured 2026-09-21 on the served 0.16.0 build, and re-checked by
    // web/test/shell-credits-role-gate.test.js against whatever build is installed: handler
    // omitted → the button still renders, with no onClick — a dead control, not "no link".
    // (This comment used to say /upgrade must stay because Stripe's return URLs land there. On
    // 2026-09-21 `success_url|cancel_url|return_url` matched nothing in server/src or web/app
    // outside this comment, and there is no services/billing.ts — Edge's checkout is gone.)
    onOpenBilling: suiteCreditsHref
      ? () => { window.location.href = suiteCreditsHref; }
      : () => navigate("/upgrade"),
    // #279 — opens the shared SupportDialog ("How can we help?"), the same component Coach and the
    // Suite open from this button. Shell owns the open state and renders the dialog as a SIBLING of
    // the shell (like Coach's app-shell.tsx), so it is passed in rather than created here. Left
    // undefined (tests, or a host without the dialog), the shell renders no "?" at all — its
    // `onOpenSupport &&` gate — which is the honest state, not a dead button.
    ...(onOpenSupport ? { onOpenSupport } : {}),
    // #122 — the avatar is the ACCOUNT control, and Edge's /settings is not account settings: it
    // is "Brief Setup", the AI's brief configuration (company, ICP, what you sell). Evolve Suite
    // is the single place to manage an account, so this points there.
    //
    // FALLS BACK TO TODAY'S BEHAVIOUR WHEN THE SUITE'S URL IS UNKNOWN. `suiteHref` is
    // `config.suiteUrl`, which is `string | null` — navigating to a null Suite would break a
    // control that currently works, merely to the wrong place. A wrong-but-working destination
    // beats a dead one.
    //
    // Trailing slashes stripped the same way `suite-redirect.js` does, so a configured
    // `https://suite.example.com/` cannot produce a double slash.
    //
    // NOT governed by suite-redirect.js's one-way-door rule (#675): that guards INVOLUNTARY
    // redirects of SIGNED-OUT visitors, and no signed-out visitor can reach this control at all —
    // ShellNav returns null unless `ctx` resolved, which its own guard above lists signed-out as a
    // case of. So this adds no second way for a signed-out visitor to leave Edge; it is a
    // deliberate click by a signed-in one, which is a different act from being bounced out.
    onOpenAccount: suiteAccountHref
      ? () => { window.location.href = suiteAccountHref; }
      : () => navigate("/settings"),
  });
}

// ---- Mobile bars ----
function MobileTop() {
  return (
    <div className="mp-mobiletop">
      <button onClick={() => navigate("/dashboard")} style={{ background: "none", border: 0, padding: 0, cursor: "pointer" }}><Logo size={18} /></button>
    </div>
  );
}
function MobileBottom({ path }) {
  const items = MOBILE_NAV_ITEMS;
  const active = (to) => path === to || (to === "/dashboard" && (path === "/" || path === "/dashboard"));
  return (
    <nav className="mp-bottombar">
      {items.map(it => (
        <button key={it.to} className={active(it.to) ? "is-active" : ""} onClick={() => navigate(it.to)}>
          <Icon name={it.icon} size={20} /> {it.label}
        </button>
      ))}
    </nav>
  );
}

// ---- App shell ----
function Shell({ path, children, width = "mp-w-6xl" }) {
  const ctx = useShellContext();
  // #316 — the shared SupportWidget's threadKey (MP.supportChatKey()) and enabled (MP.isReturning())
  // are read at render, so the Shell must re-render on every store tick, not only when the
  // /api/shell refetch succeeds (useShellContext swallows a failed refetch). The deleted app-local
  // widget subscribed to mp-state itself; this is that subscription, one level up.
  useStoreTick();
  // #279 — the rail's "?" opens the shared SupportDialog. One instance, mounted here next to
  // ShellNav exactly as Coach mounts it next to EvolveShell; the props adapter
  // (support-dialog-props.js) is Edge's only product-specific piece.
  const [supportOpen, setSupportOpen] = React.useState(false);
  const openSupport = React.useCallback(() => setSupportOpen(true), []);
  // Only true once the shell context has actually resolved (not signed-out,
  // not still loading, not a failed fetch) AND the vendor script is present
  // — i.e. exactly when ShellNav below will render something real. Gates
  // .mp-main's margin-left in kit.css via the "mp-shell--nav-ready" class:
  // a fetch that fails permanently (expired token, persistent 500 — no
  // retry beyond the next "mp-state" event) must degrade to a full-width
  // <main>, not a permanent blank 328px gutter with no nav (review §9).
  const shellReady = !!(ctx && window.EvolveShell);
  // Read the shell's own layout constant rather than hardcoding it — falls
  // back to the documented value (64px rail + 264px sidebar) only if the
  // vendor script hasn't loaded yet.
  const shellWidth = (window.EvolveShell && window.EvolveShell.SHELL_TOTAL_WIDTH) || 328;
  return (
    <div className={`mp-shell${shellReady ? " mp-shell--nav-ready" : ""}`} style={{ "--shell-width": shellWidth + "px" }}>
      <ShellNav path={path} ctx={ctx} onOpenSupport={openSupport} />
      {shellReady && window.EvolveShell.SupportDialog && window.supportDialogProps && (
        <window.EvolveShell.SupportDialog
          open={supportOpen}
          onOpenChange={setSupportOpen}
          {...window.supportDialogProps("support")}
        />
      )}
      {/* pitchiq#257 — the More menu's add / enable / request dialogs; the rail's onSelectApp opens them. */}
      {shellReady && window.MoreAppDialogs && <window.MoreAppDialogs ctx={ctx} />}
      <main className="mp-main">
        <MobileTop />
        <div className={`mp-container ${width}`}>{children}</div>
      </main>
      <MobileBottom path={path} />
      {/* #316 — the shared SupportWidget, mounted here next to the SupportDialog; the props adapter
          (support-widget-props.js) is Edge's only product-specific piece. Gated on the vendor script
          only — NOT on shellReady like the dialog: the deleted app-local widget rendered on
          MP.isReturning() alone, and support must stay reachable exactly when GET /api/shell is failing.
          Signed-out is the adapter's `enabled: false` (the widget then renders nothing). */}
      {window.EvolveShell && window.EvolveShell.SupportWidget && window.supportWidgetProps && (
        <window.EvolveShell.SupportWidget {...window.supportWidgetProps()} />
      )}
    </div>
  );
}

// ---- Onboarding shell (pitchiq#208) ----
// Edge Onboarding "Navigation during onboarding" + Edge Navigation "Incomplete setup": Start,
// Analyzing, Refine and Review all render the shared Sidebar/Tool Bar in the incomplete-setup
// state, including during loading, validation and errors — one static `inSetup` flag, not
// per-phase wiring, since OnboardingPage's own phase switch lives entirely inside `children`.
//
// Deliberately NOT `<Shell>`. `.mp-main`'s 38/44/80 padding and `.mp-container.mp-w-6xl`'s
// 1160px max-width are the DASHBOARD content frame; onboarding's own `.mp-onb` (kit.css) is
// already a full-height (100vh), edge-to-edge flex column with its own header and scroll
// region, and the spec forbids altering "the existing content layout... or responsive
// behavior" — wrapping it in `.mp-main`/`.mp-container` would add that padding/max-width AND
// nest a second 100vh region inside a padded box (double scroll). Figma's own onboarding
// frames (`41:12017`) confirm the shape instead: each is exactly two children, a 328px
// "Sidebar" beside a "Body" that starts flush at its right edge with no extra inset — matched
// here by giving `.mp-onb` itself the same `margin-left` `.mp-main` gets (kit.css
// `.mp-shell--onboarding.mp-shell--nav-ready .mp-onb`), and nothing more.
//
// #400 — the SupportWidget DOES mount here too (prod QA, 2026-09-25): onboarding is a signed-in
// page, per Marcos's "enabled for all of them" ruling, and the specs' silence on it was never a
// deliberate exclusion.
function OnboardingShell({ path, children }) {
  const ctx = useShellContext();
  // #400 — same reason as Shell's own call below: the SupportWidget's threadKey/enabled are read
  // fresh at render, so a store tick (e.g. a workspace switch mid-onboarding) must re-render this.
  useStoreTick();
  const [supportOpen, setSupportOpen] = React.useState(false);
  const openSupport = React.useCallback(() => setSupportOpen(true), []);
  const shellReady = !!(ctx && window.EvolveShell);
  const shellWidth = (window.EvolveShell && window.EvolveShell.SHELL_TOTAL_WIDTH) || 328;
  return (
    <div className={`mp-shell mp-shell--onboarding${shellReady ? " mp-shell--nav-ready" : ""}`} style={{ "--shell-width": shellWidth + "px" }}>
      <ShellNav path={path} ctx={ctx} onOpenSupport={openSupport} inSetup />
      {shellReady && window.EvolveShell.SupportDialog && window.supportDialogProps && (
        <window.EvolveShell.SupportDialog
          open={supportOpen}
          onOpenChange={setSupportOpen}
          {...window.supportDialogProps("support")}
        />
      )}
      {/* pitchiq#257 — the More menu's add / enable / request dialogs; the rail's onSelectApp opens them. */}
      {shellReady && window.MoreAppDialogs && <window.MoreAppDialogs ctx={ctx} />}
      {/* #400 — the shared SupportWidget, the SAME props adapter `Shell` uses above (one source of
          props, not a copy), so the thread key, transport and cookie-driven pinned/width/tab state
          all follow the user here exactly as they do everywhere else. Same gate as `Shell`'s own
          mount: on the vendor script only, NOT shellReady — support must stay reachable exactly
          when GET /api/shell is failing, onboarding included. */}
      {window.EvolveShell && window.EvolveShell.SupportWidget && window.supportWidgetProps && (
        <window.EvolveShell.SupportWidget {...window.supportWidgetProps()} />
      )}
      {children}
    </div>
  );
}

// ---- Page header ----
function PageHeader({ title, subtitle, eyebrow, back, actions }) {
  return (
    <div className="mp-pagehead mp-fade">
      {back && (
        <button className="mp-back" onClick={() => navigate(back.to)}>
          <Icon name="ArrowLeft" size={15} /> {back.label}
        </button>
      )}
      <div className="mp-pagehead__top">
        <div>
          {eyebrow && <div className="mp-eyebrow mp-pill-eyebrow" style={{ marginBottom: 10 }}>{eyebrow}</div>}
          <h1 className="text-heading-1">{title}</h1>
          {subtitle && <p className="mp-pagehead__sub">{subtitle}</p>}
        </div>
        {actions && <div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>{actions}</div>}
      </div>
    </div>
  );
}

Object.assign(window, {
  useRoute, navigate, matchRoute, Shell, ShellNav, OnboardingShell, PageHeader, NAV_ITEMS, MOBILE_NAV_ITEMS,
  NAV_LISTS, MobileBottom, CREDITS_UNKNOWN,
  // #269 — shared with pages-billing.jsx's Add-credits card; #211 — the credit dialog's destinations
  useShellContext, suiteAddCreditsHref, suiteCreditDestinations, resetShellContext,
  // evolve-suite#1343 — what an in-Edge workspace switch waits on behind its loader (exported for its test)
  shellContextSettled,
  // #315 — Home's credit figures, from the answer its Shell already loaded
  useCachedShellContext,
  // entitlement meters (shared with pages-dash.jsx)
  fmtExpiryDate, daysUntil, useStoreTick
});
