// --------------------------------------------------------------------------- // shell/Shell.tsx — the STANDALONE React shell (wave-4 scaffold; EXIT wave 1 // gives it a real door and a real nav). // // WHAT THIS IS. The successor-shaped outer frame the 2026-07-23 decision named // ("React over a thin API — the same contract the OM-6 MCP server exposes"). // It STRANGLES, it does not replace: exactly one surface is native today — the // Customer table, the component tree the Streamlit embed already ships — and // every other surface is an honest link into the current application. A page // moves here when its data contract is served by the API, never before; the // nav never pretends otherwise. // // WHAT CHANGED IN EXIT WAVE 1 (X5 + X6): // · The door is per-user (`core/users` accounts over X2/X3), not a shared // HTTP Basic password. SHELL.md's ladder step-1 "SANDBOX user key until // real auth lands" is SUPERSEDED — there are real sessions from day one. // · The nav is SERVER-FILTERED and registry-driven (`GET /api/v1/nav`), not a // static array hand-mirroring core/registry.py. What a user sees is what // `may_open` granted them. // · There is NO client-side nav fallback and no hard-coded surface. An // undeclared surface is denied — including by omission from the payload. // // WHAT THIS IS NOT. Not Next.js — recorded decision: one component tree, one // build system (the same Vite workspace whose OTHER config builds the embed // bundle), no SSR/SEO need behind a login gate. The shell frame is the cheap, // swappable part; the components are the asset. Revisit at the white-label // trigger (OM-7), where multi-tenant routing might earn Next. // // Routing is a ~20-line hash router on purpose: a handful of routes do not earn // a dependency the embed bundle would have to keep excluded. // --------------------------------------------------------------------------- import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; /** * ⭐ WAVE-27 item 34 (I5) — ROUTE-LEVEL CODE SPLITTING. * * Six surfaces that are already mounted CONDITIONALLY (an automation route, the settings modal, * the connectors page, home, the template picker, the alerts pane) become their own chunks. The * shell downloads them when a person actually opens one — which for most sessions is never, and * for every session is not the first paint. * * ⚠ EVERY ONE OF THESE WAS ALREADY BEHIND A CONDITION IN THE JSX, and that is what makes the * split real rather than cosmetic: a `lazy` whose mount renders unconditionally downloads on the * first paint anyway and buys a Suspense boundary for nothing. `CustomerGrid` is deliberately NOT * in this list, for the mirror-image reason — it IS the first paint on nearly every route, so * splitting it would add a round trip to the common case. (The build reports it as an INEFFECTIVE * dynamic import from `RecordDetail`; that is expected and correct — that import exists to break * a module CYCLE, not to move bytes. See the note beside it.) * * ⚠ `SettingsModal` is a NAMED export, so its loader maps to `{ default }`. A default-import * `lazy()` over a named export fails at RUNTIME, on click, and only for that one surface. */ const AutomationSurface = lazy(() => import("../automation/AutomationSurface")); import CustomerGrid from "../customer-grid/CustomerGrid"; import { clearCustomersCache, currentSurfaceScope } from "../customer-grid/apiBridge"; import { OverlayProvider } from "../customer-grid/OverlaySurface"; // ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows, // so it no longer has to tell the grid that it did. import { API_V1, AUTOMATION_OPEN_EVENT, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT, VIEW_OPEN_EVENT, signal } from "../apiContract"; import { PageSurface } from "../pages/PageSurface"; const SettingsModal = lazy(() => import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal }))); import type { SettingsSection } from "../settings/SettingsModal"; import { Brand } from "./Brand"; import { ErrorBoundary } from "./ErrorBoundary"; import LoginPage from "./LoginPage"; import { ASSISTANT_ROUTE, CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, INBOX_ROUTE, MAX_NAV_FOLDERS, NAV_TIMEOUT_STATUS, appLink, canonicalRoute, databaseEntries, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, QUERY_ROUTE, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav"; import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav"; // ⭐ 2026-08-14 — the database frame, extracted so the Query module can wear the SAME one. import { DbHead, DbIcon, gridScopeFor } from "./dbFrame"; const HomePage = lazy(() => import("../home/HomePage")); const TemplatePicker = lazy(() => import("../home/TemplatePicker")); const ConnectorsPage = lazy(() => import("../connectors/ConnectorsPage")); // WAVE 23 C9 (W23-W2) — SESSION D's public page. A STATIC import, not a lazy one: the wave's own // rule is that a required import fails the build when the file is not there, which is the whole // difference between a mount and an intention (verify_wiring's header). import FormPublic from "../forms/FormPublic"; // ⚠ EAGER, not `lazy()`, and deliberately — same call as `FormPublic` beside it. A published link // is a stranger's FIRST request; a lazy chunk would cost them a second round trip before anything // paints, to save a signed-in user bytes they have already paid for. The page imports nothing // from `customer-grid/**`, so it drags no spreadsheet engine along with it. import PublishedView from "../publish/PublishedView"; import { mergeRecents } from "../home/homeModel"; import type { AutomationTile } from "../home/homeModel"; // ⭐ WAVE 24 item 13 (R10, wiring W24-W3) — Home's Automations section. // ⚠ WAVE 26 ITEM 18 (R14): `stateOf` came out of this import with Home's status dot. The old note // here said it was imported rather than re-derived because it is the ONE place "is this running" // is decided — still true, and still why nobody should re-add a local copy; the frame simply has // no reason to ask any more. `listAutomations` brings the credentials and the error shape with // it, so the frame adds a fetch, not a protocol. import { listAutomations } from "../automation/automationApi"; import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras"; /** ⭐⭐ W32-T04 (R7, wiring 1): the Inbox MODULE replaces the Alerts pop-up pane. `AlertsPane` is * no longer imported here — C's `W32-T21` deletes the file, and an unmounted-but-imported * component is [[artifact-with-no-importer]] wearing the other mask. */ const InboxPage = lazy(() => import("../inbox/InboxPage")); /** ⭐⭐ W32-T05 (R1, C5, wiring 2): the Query module — E's page, A's mount. */ const QueryPage = lazy(() => import("../query/QueryPage")); /** * ⭐⭐ 2026-08-14 (owner item 2) — THE AI ASSISTANT, UNBOARDED. * * ⛔ AND THE `ai-agent` MODULE IS GONE, WHICH IS THE SAME DECISION SEEN FROM THE OTHER SIDE. * Owner, verbatim: *"Remove the 'AI agent' module completely. previous wave did not follow * instruction. AI assistant module IS AI agent module. So no need to separate it like that."* * Wave 33's own note beside that rail row had already reached the same conclusion and declined to * act on it (*"that row's destination IS dead … retiring it in favour of this one looks right"*), * raising it as ASK C-9. This is the answer to that ask: ONE AI door, and it is this one. * * ⚠ The `ai_agent` automation ACTION KIND is a different thing and stays — it is a STEP inside a * flow (`automation_engine.py`), not a module, and `POST /automations/draft` behind it is still * mounted and still gated (`api/verify_automation.py::section_w33_ai_agent`). */ const AssistantPage = lazy(() => import("../assistant/AssistantPage")); /** * Item 34's Suspense boundary, one definition for all six surfaces. * * ⛔ THE FALLBACK IS DELIBERATELY EMPTY, and that is a design decision rather than a stub. The * standing R6 rule is that this product shows a shared spinner mark and never load TEXT * (`verify_icons` asserts it across every source file, so a "Loading..." here would go red). * These chunks are tens of kilobytes off the same origin that just served the shell, so the * honest choice between a flash of a spinner and a frame of nothing is nothing — a spinner that * appears and vanishes within one frame reads as a glitch, not as progress. * * ⭐ WAVE 30 (R5) — AND AN ERROR BOUNDARY, INSIDE THE HELPER RATHER THAN AT SIX CALL SITES. * Three of these six (`TemplatePicker`, `SettingsModal`, `AlertsPane`) are siblings of `
`, * so the content-area boundary below cannot cover them; putting it here covers all six with one * edit. It also catches what `Suspense` alone cannot: a REJECTED chunk fetch. `lazy()` throws its * rejection at render, which before today unmounted the whole product because one deploy-time * cache miss made a modal's JavaScript unreachable. */ function Lazily({ children, surface }: { children: ReactNode; surface: string }) { return ( {children} ); } import { createAlert, fetchInbox } from "../alerts/alertsApi"; import { ALERT_CREATE_EVENT, EMPTY_INBOX, badgeText, parseAlertCreate, // ⚠ `routeForTopic` left this import with the pane (W32-T04). It is still exported and still // used — `inboxModel.routeForTarget` is its caller now — but the FRAME no longer asks the // topic→route question directly, which is the point of C3: one owner for that table. } from "../alerts/alertsModel"; import type { Inbox } from "../alerts/alertsModel"; // ⭐ W32-T04 (C3): the target→surface question lives in C's module, so the frame's // dispatcher is a switch over two literals rather than a second copy of the route table. import { retryEmit, routeForTarget } from "../inbox/inboxModel"; import ShareDialog from "./ShareDialog"; import { SHARE_OPEN_EVENT, parseShareRequest } from "./shareModel"; import type { ShareRequest } from "./shareModel"; // WAVE 19 R8 — the chosen database mark. Read-only across the session fence. import { FolderMark, LockMark } from "../customer-grid/icons"; import type { FolderIcon } from "../customer-grid/types"; import { isAdmin, logout, me } from "./session"; import type { SessionUser } from "./session"; /** The current application — where every not-yet-ported surface actually runs. * Deep links (`?page=`) are the app's own, so a strangler link * lands on the exact page, not a landing screen. */ const APP_BASE = (import.meta.env.VITE_AIOS_APP_URL as string | undefined) ?? "https://royal-imports-cfo-os.hf.space"; /** X5's hash route. Also the route an unauthenticated shell is pinned to. */ const LOGIN_ROUTE = "login"; /** Wave 14 C-NAVFOLD — the nav drag's PRIVATE dataTransfer MIME. Private so a drop on any * text-editable surface types nothing (a textarea is a native drop target for text/plain — * the wave-13 C-LAYOUT scar). */ const NAV_DRAG_TYPE = "application/x-loopable-nav"; /** A stable identity for "no nav yet" — see its use below. */ const NO_ENTRIES: NavEntry[] = []; /** * WAVE 23 C10 — the Database flyout's anchor, CLAMPED to the viewport. * * ⛔ `NavExtras.MenuShell` clamps both axes and this panel first did not, which is the same * omission with a bigger blast radius: the Database button sits ~200px down the rail and the * panel is up to 560px tall, so on a short laptop viewport its create footer — the three rows * that are the whole point of the redesign — lands below the fold with no way to scroll to it * (the panel's own scroll is INSIDE the list, deliberately, so the footer stays put). * * The height here is the CSS `max-height: min(70vh, 560px)`, restated. Two copies of one number * is a real cost; the alternative is measuring the panel after it mounts, which means rendering * it in the wrong place for a frame. Named and kept beside the rule it mirrors. */ const DBFLY_MAX_H = 560; export function flyoutAt(rect: { right: number; top: number }, vw: number, vh: number) { const h = Math.min(Math.round(vh * 0.7), DBFLY_MAX_H); return { x: Math.min(Math.round(rect.right + 6), Math.max(8, vw - 268 - 8)), y: Math.max(8, Math.min(Math.round(rect.top), vh - h - 8)), }; } /** * ⭐ THE ONE PLACE the Database flyout's position is measured off its button (wave-23 close-out). * * Two open-paths grew this wave — the button's own click, and "+ Create new…" opening the panel so * a just-named folder is visible — and each measured the element itself. That is the shape D-20 * exists to prevent, one pattern over: **the null case and the measurement should live in exactly * one place**, or the second caller is one refactor away from forgetting the guard. * * ⚠ Honest note on the gate, because the fix should not be mistaken for a rename. `verify_overlay` * greps for a LITERAL token — a variable named `anchor`, followed by a measure call — so the * click-path (which reads `e.currentTarget`) always passed while the ref-path failed. The needle * is a proxy for "a call site measured its own anchor", and a proxy can be satisfied by renaming a * variable. This change satisfies it STRUCTURALLY instead: there is now one measurement, one null * guard, and no second call site to keep honest. Booked as DEBT (D-49) so the gate's real subject * (anchor-rect logic outside the overlay layer) can be tightened deliberately rather than by grep. * * ⛔ AND DO NOT SPELL THE TOKEN OUT IN THIS COMMENT. The first draft of this note quoted the exact * string it was describing, and the gate — which greps raw file text — matched the explanation and * went red on a file with no defect in it. Prose about a pattern IS the pattern to a text scan; * the same trap as a marker comment placed mid-selector (verify_catalog, wave 21). */ export function flyoutFrom(el: HTMLElement | null) { if (!el) return null; return flyoutAt(el.getBoundingClientRect(), window.innerWidth, window.innerHeight); } /** * WAVE 23 C9 (wiring W23-W2) — the PUBLIC form route: `#/form/`. * * ⛔ THE TOKEN IS WHITELISTED, NOT SLICED. `secrets.token_urlsafe` mints `[A-Za-z0-9_-]`, so * anything else in that position is not a token this product issued — and passing it through * would let a crafted hash decide what `FormPublic` puts in a URL. Refusing here means the frame * falls through to its normal routing (and the visitor meets the login page), which is the * correct answer for a link that is not one of ours. * * `null` for every other route, so the branch that reads it is a single truthiness test. */ export function formTokenOf(route: string): string | null { const m = /^form\/([A-Za-z0-9_-]{8,128})$/.exec(route); return m ? m[1] : null; } /** * ⭐⭐ WAVE 33 item 8b (ruling R5, W33-T26) — the PUBLISHED VIEW route: `#/v/`. * * The same construction as `formTokenOf` immediately above, and the same reason: the token is * WHITELISTED against `secrets.token_urlsafe`'s alphabet, never sliced out of the hash. Anything * else in that position is not a token this product minted, and passing it through would let a * crafted hash decide what `PublishedView` puts in a URL. Refusing here drops the frame back into * normal routing, where the visitor meets the login page — the right answer for a link that is * not one of ours. * * ⚠ `v` RATHER THAN `view`, and it is not brevity for its own sake: `#/view/...` would sit one * typo away from the app's own routes, while `v` is a namespace nothing else claims. R5 names * this exact shape. */ export function publishTokenOf(route: string): string | null { const m = /^v\/([A-Za-z0-9_-]{8,128})$/.exec(route); return m ? m[1] : null; } type Session = | { phase: "checking" } | { phase: "anon" } | { phase: "authed"; user: SessionUser }; type Nav = | { phase: "idle" } | { phase: "loading" } | { phase: "ready"; entries: NavEntry[]; utility: NavPage[]; /** WAVE 23 C10 — the Home landing's recently-opened list, per user, off the nav payload. */ recents: Recent[]; empty?: string; /** ⭐ W31-T11 — registry keys this workspace's catalogue leaves out. Deliberate. */ omitted?: string[]; /** ⛔ W31-T11 — parts of the payload the SERVER could not read. Not deliberate. */ degraded?: string[]; } | { phase: "error"; timedOut?: boolean }; const NO_UTILITY: NavPage[] = []; /** Stable identity, like `NO_ENTRIES` — a fresh `[]` per render would re-run Home's memo. */ const NO_RECENTS: Recent[] = []; /** * ⭐⭐ W33-T22 (owner item 1) — THE TWO ROWS THE PAYLOAD DECIDES, FOUND IN ONE PLACE. * * ⚠ Both predicates had exactly one call site, in the render body, and now have two — the render * and the memory effect below. Written out here so the two cannot answer differently: "is * Automation on this rail?" must not be `kind === "native"` in one place and `truthy` in the * other, or the remembered rail reserves a slot the render never fills [[one-question-two-normalizers]]. */ const findAnalyst = (utility: NavPage[]) => utility.find((p) => p.key === "analyst"); const findAutomation = (entries: NavEntry[]) => entries.find((e) => e.key === "automation" && e.kind === "native"); /** * ⭐⭐ W33-T22 — WHAT THIS ACCOUNT'S RAIL LOOKED LIKE LAST TIME, so first paint need not guess. * * ⛔ THE PROBLEM A PLACEHOLDER ALONE DOES NOT SOLVE, and it was found by a reviewer reading the * fix rather than by any gate. Holding a slot for a conditional row fixes the common case — the * row lands and fills the box it was already occupying — but for an account that does NOT have * the surface, the slot is reserved at first paint and then COLLAPSES when `/nav` says "silent". * That is a flash-then-shove, i.e. arguably worse for that user than the bug we set out to fix, * and `silent` must keep collapsing: reserving permanent space for a surface the server did not * grant would be the hard-coded row this frame refuses to have (see the file header). * * ⭐ SO THE FIRST PAINT REPRODUCES THE LAST ONE. On every `ready` payload we record which of the * two conditional rows this account actually had; on the next load we reserve a slot only for the * rows it had. A returning user therefore gets their FINAL rail geometry in the first paint, * whichever way it goes, and `/nav` only ever confirms it. * * ⚠ KEYED BY USERNAME, NOT BY BROWSER. `aios-nav-collapsed` above is a per-BROWSER preference and * that is right for it; this is a per-ACCOUNT fact about permissions, and on a shared machine an * un-keyed version would paint one person's grants for another. `who` is known at first paint * because the shell is released on `/me`, which is the same fact that makes this bug possible. * * ⛔ UNKNOWN IS NOT FALSE. A first-ever load has no memory, and the honest answer there is to * RESERVE the slot (`pending`), not to assume absence — an absent row that then arrives is the * original defect. Only a remembered, explicit `false` suppresses the placeholder. Same on a * blocked/parse-failed storage: fall back to reserving. */ const RAIL_MEMORY_KEY = "aios-rail-slots"; function railMemory(who: string | null): Record { if (!who) return {}; try { const all = JSON.parse(localStorage.getItem(RAIL_MEMORY_KEY) || "{}"); const mine = all && typeof all === "object" ? all[who] : null; return mine && typeof mine === "object" ? (mine as Record) : {}; } catch { return {}; } } function rememberRail(who: string | null, slots: Record) { if (!who) return; try { const raw = localStorage.getItem(RAIL_MEMORY_KEY); const parsed = raw ? JSON.parse(raw) : {}; const all = parsed && typeof parsed === "object" ? parsed : {}; all[who] = slots; localStorage.setItem(RAIL_MEMORY_KEY, JSON.stringify(all)); } catch { // Storage can be blocked. The rail then falls back to reserving the slot, which is the // safe direction: a placeholder that turns into a row beats a row that appears from nowhere. } } function useHashRoute(): string { // ⭐ W32-T04 (C3): a RETIRED key is translated here, before anything reads it — so the whole // shell (`resolveRoute`, `active`, every `route === X` below) sees one vocabulary and no reader // has to know a rename happened. `canonicalRoute` is identity for every live key. const read = () => canonicalRoute(window.location.hash.replace(/^#\/?/, "")); const [route, setRoute] = useState(read); useEffect(() => { const onChange = () => setRoute(read()); window.addEventListener("hashchange", onChange); return () => window.removeEventListener("hashchange", onChange); }, []); return route; } /** A surface that still lives in the current application. Honest copy, one * action — never a mock of the page it is not. */ function StranglerPage({ entry }: { entry: NavEntry }) { return (

{entry.label}

This surface runs in the current application.

Open {entry.label}
); } // --- the three nav glyphs (SVG, never emoji — the platform's own rule) ------ /** The database cylinder every source-backed row carries, mirroring the host * nav's Material `database` glyph. */ /** * ⭐ THE MAGNIFIER — the Query rail row's mark (owner, 2026-08-14: *"Change the icon for * Query, it should just be a magnifying glass icon"*). * * ⚠ IT REPLACES `SparkIcon` ON THAT ROW, and the row it leaves behind is the one that * still means "AI": the assistant. Two rows wearing the same sparkle said the two were * the same surface — which is exactly the confusion the owner is unwinding in this pass. * Stroke-only at the same 16-box as `DbIcon` / `BellIcon`, so the rail's optical weight * is unchanged. */ function SearchIcon() { return ( ); } /** The host's `auto_awesome` sparkle for the AI-assistant slot (app.py:8166). */ function SparkIcon() { return ( ); } /** The new-tab affordance on a hand-off row: this destination opens in the * current application, and the arrow says so before the click does. */ function ExtIcon() { return ( ); } /* ⭐ `DbHead` AND `DbIcon` MOVED TO `shell/dbFrame.tsx` (2026-08-14), unchanged in behaviour. They were private to this file, and the owner's Query instruction — *"the query module should exactly BE looking like the database module, COMPLETELY"* — makes them a SECOND surface's header too. `gridScopeFor` went with them, and that one is not a move but a fix: the registry key and the grid scope are different spellings, and the map used to live inline in the native branch below where nothing else could reach it. See that file's header. */ /** The ⋯ affordance on the account row. */ function DotsIcon() { return ( ); } /** WAVE 19 R12 — the gear beside the account menu's one Settings row. Drawn in * the same 16x16 stroke vocabulary as the rest of this file (SVG, never an * emoji — the platform's own rule), so it sits beside `DbIcon` and `SparkIcon` * as one family rather than as an icon borrowed from somewhere else. */ function GearIcon() { return ( ); } /** WAVE 19 R10 — Automation's own mark, now that it is a standalone rail button * rather than a row in the database list. Two nodes and the edge between them: * a flow, which is what the surface actually is, and visibly not a database. */ function AutoIcon() { return ( ); } /** WAVE 20 item 25 — the Alerts row's mark. A bell, in the same 16x16 stroke * vocabulary as its neighbours, so it sits beside the sparkle and the flow rather * than arriving from another icon set. */ function BellIcon() { return ( ); } /** WAVE 23 R7 — Home's mark. A house, in the same 16x16 stroke vocabulary as its neighbours; * the reference rail uses one and there is no more literal glyph for "the landing". */ function HomeIcon() { return ( ); } /** WAVE 23 R8 — Connectors: two links of a chain, which is exactly what a connector is. The * same glyph the Home card draws, so the nav row and the card that leads to it agree. */ function PlugIcon() { return ( ); } /* ⛔ `PlusMark` LIVED HERE AND IS DELETED WITH THE THREE BUTTONS THAT DREW IT (wave 24 item 15a, ruling R9). It was the flyout footer's plus, used by nothing else — `CreateNewRow` brings its own `PlusIcon`, which is private to `NavExtras`. `tsc` named it the moment the last call site went (TS6133), which is the argument for a REQUIRED prop and an unused-locals check doing the remembering: the CSS twins `.shell-dbfly-make` and `.shell-dbfly-plus` went to zero call sites in the same edit and no compiler says a word about those — they are swept by hand below, in this session's own index.css region, rather than left as the D-42 residue class. */ /** WAVE 23 R7 — the disclosure caret on the "Database" row: this opens a panel, and the mark * says so before the click does (the same job `ExtIcon` does for a hand-off). */ function CaretIcon() { return ( ); } /** Owner item 11 — the rail toggle: three horizontal bars. One glyph for both rails (the * views rail draws the same geometry), so "minimize a panel" reads as one idea. */ function RailToggleIcon() { return ( ); } /** * The account corner: ONE line (monogram + name + role, the host's * `_account_css` card) that opens a small menu, instead of three buttons * crushed into a 236px row — which is what the owner met as "the logout * button broke": Sign out wrapped into a 42px two-line stub beside a clipped * name. Every action keeps its full label inside the menu. */ function AccountMenu({ user, utility, onSignOut, onSettings, }: { user: SessionUser; /** `chrome:'utility'` pages other than the Analyst slot — placed here, where * the host keeps them (Settings in the chrome, not the module list). */ utility: NavPage[]; onSignOut: () => void; onSettings: (s: SettingsSection) => void; }) { const [open, setOpen] = useState(false); const wrap = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [open]); // ⚠ Unicode letter/number, not [a-z0-9]: the app's `_account_css` uses // Python's `str.isalnum()`, so an ASCII-only class here would give a // non-Latin name an initial in one shell and a blank circle in the other. const initial = (user.name.match(/[\p{L}\p{N}]/u)?.[0] ?? "").toUpperCase(); const extras = utility.filter((p) => p.key !== "analyst"); return (
{open ? (
{/* ⚠ WAVE 19 R12 SUPERSEDES WAVE 15 R12, IN BOTH HALVES. Wave 15 renamed this row "Settings" → "Profile" and kept a second, admin-only "Manage users" beside it. The owner has now ruled the opposite: ONE row, called **Settings**, with a gear, shown to EVERYONE, landing on Account — and admins meet "Manage users" where it always really lived, as a tab in the modal's own rail. The two rows were the problem, not either label. They opened the SAME modal at two different tabs, so the account menu was quietly acting as a second navigation for a surface that already has one; an admin had to decide which door to use before knowing which tab they wanted. Landing everyone on Account and letting the rail do the rest removes the decision. ⛔ `verify_ui.py`'s naming gate was inverted in the same change (its R12 check asserted the wave-15 wording, so it would have gone red on the correct edit). Wave-19 amendment 1 in the wave doc. */} {extras.length ?
: null} {extras.map((p) => ( setOpen(false)} > {/* WAVE 17 ITEM 15b (R8) — the `settings` special-case is GONE. This row used to relabel one registry key on its way to the screen, which is a client deciding what the payload meant. R8 deletes the host surface itself, so the honest client change is not to HIDE the key here — hard-coding an exclusion would outlive the thing it excludes — but to stop treating any key specially and let the payload decide. When `nav_pages` stops emitting it, the row stops existing, with nothing here to update. Every utility row now wears its registry label. */} {p.label} ))}
) : null}
); } /** * ⭐ WAVE 30 (R5) — THE OUTERMOST BOUNDARY, and the ONLY one that can cover the three * EARLY RETURNS below (`FormPublic`, the boot card, `LoginPage`) plus this component's own * body: its route resolution, its nav shaping and its derivations all run BEFORE any JSX * exists to wrap. A throw in any of them used to take the document with it, and a person * who cannot get past the login screen has no navigation left to escape with. * * ⚠ Split rather than nested inline for one reason: a boundary cannot catch a throw from * its OWN render. `ErrorBoundary` has to be the parent of the component that fails, so the * frame becomes a child and the default export becomes the wrapper. */ export default function Shell() { return ( ); } function ShellFrame() { const [session, setSession] = useState({ phase: "checking" }); const [nav, setNav] = useState