// --------------------------------------------------------------------------- // 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 } 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 { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, INBOX_ROUTE, MAX_NAV_FOLDERS, NAV_TIMEOUT_STATUS, appLink, canonicalRoute, databaseEntries, dbChipClass, 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"; 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"; 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")); /** * 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; } 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[] = []; 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. */ function DbIcon() { 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 ( ); } /** * WAVE 20 item 4 (R8 / C-ADDROW) — THE UNIVERSAL DATABASE HEADER. * * One header, identical on every database (`reference/Airtable 6.png`): a chip in * a bold colour carrying the database's mark, then its name. It replaces * `shell-ut-bar`, which existed on USER TABLES only — so the two built-in * databases had no header at all, and the one place the product named the thing * you were looking at appeared or vanished depending on where the table came * from. R8 makes it universal. * * The "Add record" button the old bar carried is GONE with it, deliberately: R8 * replaces it with the grid's own trailing "+" row (S3's half of C-ADDROW), on * user databases only — a connector-backed table's rows are read-synced, and a * "+" that must refuse is a fake affordance. * * The chip is decorative and says so: the name beside it is real text, so a * second announcement of the same fact is noise to a screen reader. */ function DbHead({ label, icon, glyph }: { label: string; icon?: FolderIcon; glyph?: ReactNode }) { return (
{/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself, and the name of the thing you are looking at is what a heading is for. Every other full-pane surface in this shell (`shell-placeholder`) already uses one, and they never render together — so the page gains a heading rather than a second one. */}

{label}

); } /** 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