// --------------------------------------------------------------------------- // 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 { AGENTS_CHANGED, 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, FEEDBACK_ROUTE, HOME_ROUTE, INBOX_ROUTE, NO_SUBSCRIPTION_TENANT, STARRED_ROUTE, SUBSCRIPTION_ROUTE, USAGE_ROUTE, MAX_NAV_FOLDERS, NAV_TIMEOUT_STATUS, appLink, canonicalRoute, databaseEntries, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, QUERY_ROUTE, resolveRoute, rewrittenHash, routeKeyOf, 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 { DbIcon, gridScopeFor } from "./dbFrame"; /* ⭐ W35-T09 — the app's own star (B-3): one mark, one meaning (DESIGN.md §4). */ import { StarIcon } from "../ui/icons"; /* ⭐ W35-T09 — the assistant's EXISTING door for opening an AI-built view: hash + a retry ladder, returning a canceller. A second emit here would be a second answer. */ import { openBuiltView } from "../assistant/AssistantPage"; const HomePage = lazy(() => import("../home/HomePage")); const TemplatePicker = lazy(() => import("../home/TemplatePicker")); const ConnectorsPage = lazy(() => import("../connectors/ConnectorsPage")); /* ⭐ WAVE 35 · T09 (wiring W1) — the Starred surface, session B's tree. */ const StarredPage = lazy(() => import("../starred/StarredPage")); /* ⭐ WAVE 35 · T06 (contract C6, wiring W2) — the three account surfaces, session B's tree. Lazy like every other page here: none of them is on the path to first paint, and all three are behind a menu the user has to open. Default exports, as B published them — `lazy()` over a NAMED export fails at RUNTIME, on click, and only for that one surface (the note at the top of this file). */ const FeedbackPage = lazy(() => import("../account/FeedbackPage")); const UsagePage = lazy(() => import("../account/UsagePage")); const SubscriptionPage = lazy(() => import("../account/SubscriptionPage")); // 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. */ // W33-T75 supersedes the T22 account-memory approach above. A fresh account has no remembered // row set, so optional destinations retain an inert final skeleton rather than collapsing after // `/nav` resolves. The skeleton has no label, route, focus target, or source of permission. /** * ⭐⭐ W33-T75 (owner item 1, `reference/ERROR 6.png`) — THE RAIL PAINTS ONCE. * * ⛔ THE ACCEPTANCE TEST IS NOT "THE SPINNER IS GONE", IT IS "NO ROW IS EVER IN A DIFFERENT * STATE FROM ITS SIBLINGS." The owner has reported this four times. Every previous answer kept * the same shape — five finished rows plus one or two rows wearing a *distinguishable* loading * state — and shrank the difference: `/nav` went 11.0 s → 26 ms (still two paints, because * `:911` makes `entries` `NO_ENTRIES` until `nav.phase === "ready"`, so first paint happens * while "does this workspace have Automation / an Analyst?" is unknown at ANY speed); then W31-T11 * gave Automation a spinner slot; then W33-T22 gave the Assistant one too. `ERROR 6.png` is the * result: Home / Query / Inbox / Database / Connectors drawn in ink, **AI assistant and Automation * drawn as spinners**, and a sixth spinner below Connectors. Two states in one list is exactly * what "the navigation loads separately" describes, and a placeholder that ANNOUNCES itself as * pending is a second state however tall it is. * * ⛔ SO THE FIX IS AT THE OTHER END: while `/nav` is in flight the rail draws THIS and no rows at * all. One skeleton, seven identical bars, nothing claiming to be a destination — then the real * rail arrives in a single paint. There is no frame in which one row is further along than * another, which is the only form of the sentence that cannot come back a fifth time. * * ⚠ WHAT IT COSTS, STATED RATHER THAN DISCOVERED: Home / Query / Inbox / Database / Connectors * are not clickable for the length of the `/nav` round trip, where before they were. That is a * real trade and it is the owner's own priority — measured, `/nav` is **0.77 s cold on the * deployed build and 26 ms warm** (wave 33 close record), so the window is short; and the four * waves of reports are about the rail's shape, never about waiting for it. `phase: "error"` and * `degraded` are UNCHANGED: they draw the real rows with their unavailable arms, because there * the answer has arrived and is bad news, which is a fact worth showing rather than a wait. * * ⚠ The count is the rail's own maximum, not a guess. WAVE 34 R15/R23 took it from 7 to **6**: * Home · Inbox · Assistant · Agents · Database · Connectors. Query lost its row to the Chat/Query * toggle (R14), and there was never a seventh "Alerts" row — Inbox IS that surface. * ⛔ KEEP THIS NUMBER >= THE SETTLED ROW COUNT AND NEVER BELOW IT. A skeleton one bar TALLER than * the settled rail is a shrink, which is fine; one bar SHORTER is the appear-from-nowhere shove * W33-T22 was written about, arriving by a different door. The two `.shell-nav-sep` hairlines are * deliberately NOT counted: they are hidden by the same `is-rail-loading` rule as the rows. */ const RAIL_SKELETON_ROWS = 6; function RailSkeleton() { return (
{Array.from({ length: RAIL_SKELETON_ROWS }, (_, i) => ( ))}
); } 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. // // ⛔⛔ W34-T15 — `routeKeyOf` REPLACES `.replace(/^#\/?/, "")`, AND THAT IS THE WHOLE TICKET. // Lane D found it before it could ship (post D-1): the old expression took the WHOLE remainder // of the hash as the key, so `#/assistant?mode=query` resolved to the key // `assistant?mode=query`, matched no route, and fell through to Home. The redirect would have // put the right URL in the address bar and the wrong screen under it, and a gate asserting the // redirect's target string would have been green over it. const read = () => canonicalRoute(routeKeyOf(window.location.hash)); // ⭐⭐ W34-T15 (R14 / C2) — `#/query` BECOMES `#/assistant?mode=query`, IN THE ADDRESS BAR. // // ⚠ A REWRITE, NOT A RESOLVE, and `LEGACY_ROUTES` alone would not do: the merged surface picks // which list to show from `?mode=` on the hash, so a translation that never touched the URL // would land a Query bookmark on the CHAT list. // // ⛔⛔ AND IT IS IN THE INITIALISER RATHER THAN AN EFFECT, WHICH IS NOT A STYLE CHOICE. // `AssistantPage` resolves its mode ONCE, in its own `useState` initialiser, by reading // `window.location.hash` (`assistant/AssistantPage.tsx::initialMode` — the hash wins, else the // stored preference). Children initialise AFTER their parent renders and BEFORE any effect // runs. So a rewrite in a `useEffect` here would land after the assistant had already read the // OLD hash, found no `?mode=`, fallen back to the stored preference and opened the Chat list — // right URL, wrong screen, and nothing anywhere to say so. Rewriting during this initialiser // updates `window.location.hash` synchronously, so the child reads the new one. // // ⚠ Safe to run twice (React StrictMode does): `rewrittenHash` answers null once the key is // `assistant`, so the second call is a no-op. `replace` rather than assignment, so a bookmark // redirect does not leave a back-button trap on the hash it came from. const [route, setRoute] = useState(() => { const next = rewrittenHash(window.location.hash); if (next) window.location.replace(next); return read(); }); useEffect(() => { // The SAME rewrite for a hash typed into the bar mid-session, where there is no child // initialiser to race: by then the assistant is mounted and reads `?mode=` on its own. const onChange = () => { const next = rewrittenHash(window.location.hash); if (next) window.location.replace(next); 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. */ /* ⛔ `SearchIcon` LIVED HERE AND IS DELETED WITH THE QUERY RAIL ROW (wave 34, R14). It was the magnifier the owner asked for on 2026-08-14 (*"Change the icon for Query, it should just be a magnifying glass icon"*) and the Query row was its ONLY call site — `tsc` said so (TS6133) the moment that row went, which is the unused-locals check doing the remembering, exactly as `PlusMark`'s note one screen down predicted it would. ⚠ A COMMENT IN THIS FILE CLAIMED OTHERWISE FOR ABOUT A MINUTE. The first draft of the Query deletion asserted that "the flyout's own filter box draws it" and kept the function on that basis. It does not, and `tsc` refuted it immediately. Left here because the useful half is the habit, not the slip: a claim about who calls a symbol is checkable in one command, so check it rather than reason about it. The magnifier is not lost — `#/assistant`'s Query mode is where that vocabulary lives now. */ /** 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 ( ); } /* ⭐ `DbIcon` AND `gridScopeFor` LIVE IN `shell/dbFrame.tsx` (moved there 2026-08-14). They were private to this file, and the owner's Query instruction — *"the query module should exactly BE looking like the database module, COMPLETELY"* — made them a SECOND surface's too. `gridScopeFor` 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. ⛔ `DbHead` MOVED WITH THEM AND IS NOW DELETED (W36-T51, owner item 7 — *"Remove the header for database completely"*). Its tombstone, and the list of what must NOT be swept away with it, is in `dbFrame.tsx` where it lived. */ /** 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 36 · T50 (owner item 5) — THE OTHER FOUR ACCOUNT-MENU MARKS, so the menu is one column of icons rather than one gear and a stack of bare text. W35-T06 shipped Feedback, Usage credits and Subscription as rows with no glyph beside `GearIcon`, which left their labels starting 24px further left than Settings' — the raggedness is what the owner is looking at when item 5 says these rows "need their icon". ⚠ THEY ARE DEFINED HERE, BESIDE THE GEAR, AND NOT IN `ui/icons.tsx` — checked before choosing. That module is the shared presentation layer, its glyphs are `viewBox="0 0 24 24"` and it carries Download/Check/Alert/Bell/Star only; the account menu's vocabulary is the 16x16 box plus the `.shell-menu-icon` class (`fill:none`, `stroke: currentColor`, 1.2, muted). Adding four 16px marks to a 24px module would be the drift `ui/icons.tsx`'s own header warns about, from the other direction. Same argument `GearIcon`'s note makes: one family, one file. ⛔ NO EMOJIS (owner constant, DESIGN.md) — vector paths, `currentColor`, no colour written here. */ /** Feedback: a speech bubble. The row opens a page that asks the reader to SAY something, and a * bubble is the one mark that means "your words"; an envelope would collide with Inbox's. */ function BubbleIcon() { return ( ); } /** Usage credits: a PIE — a circle with one wedge marked off. * * ⚠ NOT a coin, and the PAGE is what decided it: `UsagePage` renders a METER of consumption (its * own comment argues about what a failed read must not look like — *"a meter reading zero is the * most reassuring possible lie"*). A coin would promise a balance the surface does not show. A pie * says "this much of it is gone", which is what the page says. * * ⭐ AND THE SHAPE WAS CHOSEN BY LOOKING, NOT BY REASONING, because the first one failed the eye. * Six candidates were rendered at 16px in this exact stroke vocabulary and read at true size * (`scratchpad/shots/glyph-candidates.png`): * · an ARC-AND-NEEDLE gauge — what shipped first, and it reads as a hill or an eyebrow: the * needle touches the arc and the two merge into one stroke at 16px; * · a COIN WITH A RIM (two concentric circles) — reads as a bullseye, and it is the loudest * mark in the menu; * · a COIN STACK — that is `dbFrame.tsx::DbIcon`. The app already spends that shape on * "database", and one mark may mean one thing (DESIGN.md §4); * · a WALLET — reads as a battery; * · a gauge WITH A HUB — better than the first, still optically thin beside the gear. * The pie won on legibility and on weight: a closed circle sits at the same optical weight as * `GearIcon` above it, and nothing else in the client draws one. */ function PieIcon() { return ( ); } /** Subscription: a card. The row is about what the workspace pays with, and a card with its stripe * is legible at 16px where anything more literal (a receipt, a calendar of renewals) is not. */ function CardIcon() { return ( ); } /** Sign out: a door with the arrow leaving it. * ⚠ IT NEEDED A CSS RULE AS WELL AS A PATH, and that is the trap in adding a mark to this row: * `.shell-menu-item.is-signout` paints the row `--lp-red-deep` while `.shell-menu-icon` sets its * own `color: var(--lp-muted)`, so the glyph would have rendered grey beside red text and read as * a half-broken row. `index.css` gives the signout icon `color: inherit` at rest and on hover. */ function ExitIcon() { return ( ); } /* ⛔ `AutoIcon` LIVED HERE AND IS DELETED WITH THE AGENTS HEADER (W35-T32, owner item 2). It was the two-node flow glyph the head drew beside the word "Agents"; with the head gone it had no call site, and `tsc`'s unused-locals check named it in the same run — which is the argument for letting the compiler do the remembering rather than a sweep. ⚠ NOT the same mark as the rail's `RobotIcon`, which SURVIVES and is what owner item 5 asked for. Two glyphs, two jobs; only the header's is retired. */ /* ⭐ `BellIcon` LIVED HERE AND IS DELETED (wave 34, R15) — AND THAT DELETION CLOSES `D-207`. The Inbox row took `MailIcon`, leaving this local copy with no call site. D-207 records that `ui/icons.tsx` — the file whose own header warns that "a second hand-copy in the shell is the drift this module exists to prevent" — already carried a second `BellIcon`, and the shell had one anyway. Deleting the LOCAL copy and keeping the SHARED one is the right half to keep: the shared module is the one another surface can import. ⚠ So `ui/icons.tsx::BellIcon` is now the only bell in the client. `W34-T17` verifies that, rather than this comment asserting it. */ /** 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 34 R15 — Inbox's mark. An envelope. * * ⚠ IT REPLACES `BellIcon` ON THAT ROW, and this is a rename of the CONCEPT, not just the glyph. * Owner, 2026-08-16: *"Alerts is called Inbox and it's right below Home"* (R23) — there is no * separate Alerts row and there never was; one row has been wearing a bell while the page it * opens is an inbox. A bell says "something happened"; an envelope says "there is mail for you", * which is what the surface actually shows. * * ⛔ `BellIcon` STAYS DEFINED — `D-207` records that a SECOND hand-copy of it already exists in * `ui/icons.tsx`, and deleting this one while the duplicate survives would leave the copy as the * only bell, which is the wrong half to keep. `W34-T17` owns that reconciliation. */ function MailIcon() { return ( ); } /** WAVE 34 R15 — the Agents row's mark: a robot head inside a circle. * * Owner asked for *"a robot looking but in circle form"*. The circle is doing real work here and * is not decoration: it is what separates this row from `AutoIcon`, the two-node flow glyph that * used to mark the same module when it was called Automation. A rail where the renamed module * keeps its old mark reads as a relabelling; a rail where it takes a new one reads as the module * it now is. * * Geometry follows `iconShapes.ts`'s standing rule even though this file is not that module: * 16x16 box, stroke-only, round caps, `currentColor`, vector paths — NEVER emoji (owner constant). * The head is drawn INSIDE the ring rather than as a filled badge, so the row's optical weight * matches `HomeIcon` and `PlugIcon` instead of blooming darker than its neighbours. */ function RobotIcon() { return ( ); } /** ⭐⭐ WAVE 35 · T04 (owner item 5) — AN ACTUAL PLUG. Owner: *"change the Icon for the connector to * look like the cable head that you would put in an electrical outlet."* * ⛔ THE NAME HAS BEEN WRONG SINCE WAVE 23 and that is why nobody noticed: this function is called * `PlugIcon` and drew two links of a CHAIN. Wave 23's own note argued the chain "is exactly what a * connector is", which is a defensible idea and not the one the owner asked for. * Two prongs up, a rounded body, a cord leaving the bottom and turning right. The PRONGS are the * load-bearing part; the cord is what stops it reading as a padlock at 16px. * ⚠ GEOMETRY PUBLISHED BY SESSION B (`ASK B-5`) AND TAKEN VERBATIM, deliberately: `HomePage.tsx:: * ConnectIcon` draws the same mark for the Home card that leads here, the two files each keep * their own copy, and agreeing on the paths by hand is the only thing stopping the nav row and its * card from drifting apart again. Change one, change both. */ function PlugIcon() { return ( ); } /** ⭐ WAVE 35 · T05 (owner item 6) — Templates. Owner, of Airtable's rail: *"a 'Templates and apps' * section? I want our app to have it too but just call it Templates ... located just above our * User button."* A sheet of cards, which is what a template gallery is: one outlined page with a * second offset behind it. Same 16x16 / `currentColor` / 1.35 vocabulary as every other rail mark, * so it sits in the column without redrawing the row's weight. */ function TemplatesIcon() { 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. */} {/* ⭐⭐ WAVE 35 · T06 (owner item 13, contract C6) — Feedback · Usage credits · Subscription, *"below settings"* exactly as the owner placed them. ⛔ THE SUBSCRIPTION ROW IS HIDDEN FOR ROYAL IMPORTS **AND** ITS ROUTE IS REFUSED (the dispatch, below). C6's second trap is that hiding a row whose route still answers is not hiding — anyone who types the hash, or follows an old bookmark, walks straight in. Both halves read ONE constant, `NO_SUBSCRIPTION_TENANT`, so they cannot drift. ⚠ Plain `` rows, not buttons: these are ADDRESSES. A button would make three real routes unbookmarkable and un-middle-clickable for no gain, and the frame already owns hash routing. */} setOpen(false)} > Feedback setOpen(false)} > Usage credits {user.tenant === NO_SUBSCRIPTION_TENANT ? null : ( setOpen(false)} > Subscription )} {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