diff --git "a/web/src/shell/Shell.tsx" "b/web/src/shell/Shell.tsx" --- "a/web/src/shell/Shell.tsx" +++ "b/web/src/shell/Shell.tsx" @@ -1,2996 +1,3228 @@ -// --------------------------------------------------------------------------- -// 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, importantBadge, 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 { 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. - */ -// 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 ( - - ); -} - -/* ⭐ `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 ( - - ); -} - -/* ⭐ `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 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