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,3510 +1,3468 @@ -// --------------------------------------------------------------------------- -// 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")); -// ⭐⭐ WAVE 37 · T48 — `lazy`, like the twelve surfaces below it. This was the ONE eager import -// left in the shell, and it is the biggest module in the tree. See `App.tsx` for the measurement. -// ⚠ IT JOINS `Lazily`, NOT A BARE `Suspense`: the shared wrapper carries the ErrorBoundary, so a -// chunk that fails to load names the surface it was for instead of blanking the frame. -const CustomerGrid = lazy(() => import("../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. - */ -/** ⭐ WAVE 37 · T06 (R7) — is `route` a REAL surface this session was not granted? - * - * Returns its LABEL (so the placeholder can name what was knocked on) or `""`. - * - * ⛔ THE TABLE IS EXPLICIT AND SHORT ON PURPOSE. A generic "any route with no entry" rule would - * board every stale bookmark and every hand-typed key, which is precisely what the `!active` - * fallback to Home already handles correctly and deliberately. Only a surface that REALLY EXISTS - * can be honestly called under construction; anything else is a lie about a feature. - * ⚠ The two keys are the REGISTRY's (`registry.py`: `analyst` = "Assistant", `automation` = - * "Agents"), not the routes' — `#/assistant` is served by the `analyst` grant, and reading the - * route name as the grant key is the mismatch this comment exists to prevent. - * - * ⛔⛔ AND IT MUST READ **BOTH** BUCKETS, WHICH IS THE BUG THIS FUNCTION SHIPPED WITH FOR AN HOUR. - * The first version asked `entries` alone. `splitChrome` sends every page with `chrome === - * "utility"` to `utility` and everything else to `entries` — and `analyst` is a UTILITY page, which - * is why the shell's own `findAnalyst(utility)` exists twenty lines below. So `entries.some(key === - * "analyst")` was ALWAYS false and the Assistant read "Under construction" **for everyone, - * administrators included**. It was found by looking at a screenshot taken for a different ticket. - * ⭐ THE LESSON IS THE ONE THIS WAVE ALREADY BOOKED TWICE (`NOTE C-14`, amendment A21): reading the - * SYMBOL and not the DATA IT RESOLVES AGAINST. `entries` is a true name for a collection that does - * not contain what this function needed. - */ -function boardedFor( - route: string, - entries: ReadonlyArray<{ key: string }>, - utility: ReadonlyArray<{ key: string }>, -): string { - const BOARDED: ReadonlyArray<[string, string, string]> = [ - [ASSISTANT_ROUTE, "analyst", "Assistant"], - ["automation", "automation", "Agents"], - ]; - for (const [r, key, label] of BOARDED) { - if (route === r && !entries.some((e) => e.key === key) - && !utility.some((e) => e.key === key)) return label; - } - return ""; -} - -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 38 · T06 (owner instruction 1) — THE SHELL→GRID CHANNEL FOR "open the chat". - * - * The frame owns the minimised rail; `CustomerGrid` owns `chatOpen` and mounts ``. So - * the launcher does what every other cross-fence click-through in this frame does: it raises a - * window event and the surface answers if it can, exactly as `VIEW_OPEN_EVENT` and - * `AUTOMATION_OPEN_EVENT` already do. Nothing here learns the grid's state, which is the point: - * the shell cannot know whether a chat is already open, so this signal OPENS and never toggles. - * - * ⛔⛔ THE STRING IS TYPED TWICE, AND THAT IS A CONSTRAINT, NOT A PREFERENCE. Its natural home is - * `apiContract.ts` beside the other four signals — the host-neutral leaf both trees already - * import — and that file is outside this ticket's `files:` list. The two legal alternatives are - * both worse: `customer-grid/**` may not import `shell/**` (it is host-neutral by its own rule, - * which is what lets the grid run inside another host), and a STATIC import of a constant out of - * `CustomerGrid.tsx` would drag the whole spreadsheet engine into the shell's entry chunk, undoing - * the `lazy()` at :60 that keeps it out. So the canonical declaration is - * `CustomerGrid.tsx::GRID_CHAT_OPEN_EVENT`, this is its twin, and the divergence they invite is - * closed by a control rather than by a comment: `verify_wiring.py`'s W38-T06 row matches the - * LITERAL `aios:grid-chat-open` on both sides, so a typo in either spelling reds `web_wiring`. - * ⚠ Move both to `apiContract.ts` the next time a ticket owns that file. See PENDING. - */ -const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open"; - -/** - * 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 - 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: 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. - * - * ⛔ THAT PARAGRAPH IS STALE AND W37-T01 CORRECTS IT RATHER THAN LEAVING IT TO ROT. It read - * *"`BellIcon` STAYS DEFINED — deleting this one while the duplicate survives would leave the copy - * as the only bell, which is the wrong half to keep"*. `W34-T17` DID the reconciliation and kept - * the opposite half: the local copy is gone (see the deletion note above this file's icons) and - * `ui/icons.tsx::BellIcon` is the only bell in the client, which is the SHARED one and therefore - * the right half. **D-207 is closed**, verified by grep at wave 37: one `function BellIcon` in the - * tree. A comment asserting a duplicate that no longer exists sends the next reader hunting. */ -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 · - 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 - - {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