| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; |
| import { createPortal } from "react-dom"; |
| import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
| |
| |
| 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"; |
| |
| import { DbIcon, gridScopeFor } from "./dbFrame"; |
| |
| import { StarIcon } from "../ui/icons"; |
| |
| |
| import { openBuiltView } from "../assistant/AssistantPage"; |
| const HomePage = lazy(() => import("../home/HomePage")); |
| const TemplatePicker = lazy(() => import("../home/TemplatePicker")); |
| const ConnectorsPage = lazy(() => import("../connectors/ConnectorsPage")); |
| |
| const StarredPage = lazy(() => import("../starred/StarredPage")); |
| |
| |
| |
| |
| const FeedbackPage = lazy(() => import("../account/FeedbackPage")); |
| const UsagePage = lazy(() => import("../account/UsagePage")); |
| const SubscriptionPage = lazy(() => import("../account/SubscriptionPage")); |
| |
| |
| |
| import FormPublic from "../forms/FormPublic"; |
| |
| |
| |
| |
| import PublishedView from "../publish/PublishedView"; |
| import { mergeRecents } from "../home/homeModel"; |
| import type { AutomationTile } from "../home/homeModel"; |
| |
| |
| |
| |
| |
| |
| import { listAutomations } from "../automation/automationApi"; |
| import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras"; |
| |
| |
| |
| const InboxPage = lazy(() => import("../inbox/InboxPage")); |
| |
| const QueryPage = lazy(() => import("../query/QueryPage")); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const AssistantPage = lazy(() => import("../assistant/AssistantPage")); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function Lazily({ children, surface }: { children: ReactNode; surface: string }) { |
| return ( |
| <ErrorBoundary surface={surface}> |
| <Suspense fallback={null}>{children}</Suspense> |
| </ErrorBoundary> |
| ); |
| } |
|
|
| import { createAlert, fetchInbox } from "../alerts/alertsApi"; |
| import { |
| ALERT_CREATE_EVENT, |
| EMPTY_INBOX, |
| badgeText, |
| parseAlertCreate, |
| |
| |
| |
| } from "../alerts/alertsModel"; |
| import type { Inbox } from "../alerts/alertsModel"; |
| |
| |
| import { retryEmit, routeForTarget } from "../inbox/inboxModel"; |
| import ShareDialog from "./ShareDialog"; |
| import { SHARE_OPEN_EVENT, parseShareRequest } from "./shareModel"; |
| import type { ShareRequest } from "./shareModel"; |
| |
| 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"; |
|
|
| |
| |
| |
| const APP_BASE = |
| (import.meta.env.VITE_AIOS_APP_URL as string | undefined) ?? |
| "https://royal-imports-cfo-os.hf.space"; |
|
|
| |
| const LOGIN_ROUTE = "login"; |
| |
| |
| |
| const NAV_DRAG_TYPE = "application/x-loopable-nav"; |
|
|
| |
| const NO_ENTRIES: NavEntry[] = []; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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)), |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function flyoutFrom(el: HTMLElement | null) { |
| if (!el) return null; |
| return flyoutAt(el.getBoundingClientRect(), window.innerWidth, window.innerHeight); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function formTokenOf(route: string): string | null { |
| const m = /^form\/([A-Za-z0-9_-]{8,128})$/.exec(route); |
| return m ? m[1] : null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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[]; |
| |
| recents: Recent[]; |
| empty?: string; |
| |
| omitted?: string[]; |
| |
| degraded?: string[]; |
| } |
| | { phase: "error"; timedOut?: boolean }; |
|
|
| const NO_UTILITY: NavPage[] = []; |
| |
| const NO_RECENTS: Recent[] = []; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const findAnalyst = (utility: NavPage[]) => utility.find((p) => p.key === "analyst"); |
| const findAutomation = (entries: NavEntry[]) => |
| entries.find((e) => e.key === "automation" && e.kind === "native"); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const RAIL_SKELETON_ROWS = 6; |
|
|
| function RailSkeleton() { |
| return ( |
| <div className="shell-rail-skeleton" role="status" aria-label="Loading navigation"> |
| {Array.from({ length: RAIL_SKELETON_ROWS }, (_, i) => ( |
| <div className="shell-rail-skeleton-row" key={i} aria-hidden="true"> |
| <span className="shell-rail-skeleton-mark" /> |
| <span className="shell-rail-skeleton-bar" /> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function useHashRoute(): string { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const read = () => canonicalRoute(routeKeyOf(window.location.hash)); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [route, setRoute] = useState<string>(() => { |
| const next = rewrittenHash(window.location.hash); |
| if (next) window.location.replace(next); |
| return read(); |
| }); |
| useEffect(() => { |
| |
| |
| 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; |
| } |
|
|
| |
| |
| function StranglerPage({ entry }: { entry: NavEntry }) { |
| return ( |
| <div className="shell-placeholder"> |
| <h1>{entry.label}</h1> |
| <p>This surface runs in the current application.</p> |
| <a className="shell-link" href={entry.href} target="_blank" rel="noreferrer"> |
| Open {entry.label} |
| </a> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| function SparkIcon() { |
| return ( |
| <svg className="shell-nav-icon is-spark" viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M6.2 2.2 7.5 5.5l3.3 1.3-3.3 1.3-1.3 3.3-1.3-3.3-3.3-1.3 3.3-1.3z" /> |
| <path d="M12.2 9.4l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| function ExtIcon() { |
| return ( |
| <svg className="shell-nav-open" viewBox="0 0 12 12" aria-hidden="true"> |
| <path d="M4.6 3h4.4v4.4M8.8 3.2 3.2 8.8" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| function DotsIcon() { |
| return ( |
| <svg className="shell-account-dots" viewBox="0 0 16 16" aria-hidden="true"> |
| <circle cx="3.5" cy="8" r="1.25" /> |
| <circle cx="8" cy="8" r="1.25" /> |
| <circle cx="12.5" cy="8" r="1.25" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| function GearIcon() { |
| return ( |
| <svg className="shell-menu-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <circle cx="8" cy="8" r="2.15" /> |
| <path d="M8 1.9l.9 1.5 1.7-.4.5 1.7 1.7.5-.4 1.7 1.2 1.1-1.2 1.1.4 1.7-1.7.5-.5 1.7-1.7-.4-.9 1.5-.9-1.5-1.7.4-.5-1.7-1.7-.5.4-1.7L1.7 8l1.2-1.1-.4-1.7 1.7-.5.5-1.7 1.7.4z" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| function BubbleIcon() { |
| return ( |
| <svg className="shell-menu-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <rect x="2.2" y="3.1" width="11.6" height="8.2" rx="1.8" /> |
| <path d="M5.8 11.3 5 13.7l3.2-2.4" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function PieIcon() { |
| return ( |
| <svg className="shell-menu-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <circle cx="8" cy="8" r="5.5" /> |
| <path d="M8 2.5V8l3.9 2.3" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| function CardIcon() { |
| return ( |
| <svg className="shell-menu-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <rect x="1.9" y="4.1" width="12.2" height="7.8" rx="1.4" /> |
| <path d="M1.9 6.9h12.2" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| function ExitIcon() { |
| return ( |
| <svg className="shell-menu-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M9.4 2.7H4.2a1.4 1.4 0 0 0-1.4 1.4v7.8a1.4 1.4 0 0 0 1.4 1.4h5.2" /> |
| <path d="M11.3 5.7 13.7 8l-2.4 2.3" /> |
| <path d="M6.9 8h6.8" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
| function HomeIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M2.4 7.2 8 2.6l5.6 4.6" /> |
| <path d="M3.9 8.2v5.2h8.2V8.2" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function MailIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <rect x="2.1" y="3.9" width="11.8" height="8.2" rx="1.3" /> |
| <path d="M2.6 5.1 8 8.9l5.4-3.8" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function RobotIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M8 1.6a6.4 6.4 0 1 1 0 12.8A6.4 6.4 0 0 1 8 1.6Z" /> |
| <rect x="4.6" y="6.2" width="6.8" height="5" rx="1.4" /> |
| <path d="M6.5 8.4v.7M9.5 8.4v.7" /> |
| <path d="M8 4.3v1.9" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function PlugIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <path d="M6.1 5.2V2.1M9.9 5.2V2.1" /> |
| <path d="M12.1 5.2v3.1a2.7 2.7 0 0 1-2.7 2.7H6.6a2.7 2.7 0 0 1-2.7-2.7V5.2Z" /> |
| <path d="M8 11v1.6a1.6 1.6 0 0 0 1.6 1.6h1.5" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| function TemplatesIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <rect x="2.2" y="4.4" width="8.2" height="9.2" rx="1.3" /> |
| <path d="M5.6 4.4V3.7a1.3 1.3 0 0 1 1.3-1.3h6a1.3 1.3 0 0 1 1.3 1.3v6a1.3 1.3 0 0 1-1.3 1.3h-.7" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| function CaretIcon() { |
| return ( |
| <svg className="shell-nav-open shell-nav-caret" viewBox="0 0 12 12" aria-hidden="true"> |
| <path d="M4.4 2.6 8 6l-3.6 3.4" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| function RailToggleIcon() { |
| return ( |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| <path |
| d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11" |
| stroke="currentColor" |
| strokeWidth="1.35" |
| strokeLinecap="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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<HTMLDivElement | null>(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]); |
|
|
| |
| |
| |
| const initial = (user.name.match(/[\p{L}\p{N}]/u)?.[0] ?? "").toUpperCase(); |
| const extras = utility.filter((p) => p.key !== "analyst"); |
|
|
| return ( |
| <div className="shell-account-wrap" ref={wrap}> |
| {open ? ( |
| <div className="shell-menu" role="menu"> |
| {/* ⚠ 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. */} |
| <button |
| type="button" |
| className="shell-menu-item" |
| role="menuitem" |
| onClick={() => { |
| setOpen(false); |
| onSettings("account"); |
| }} |
| > |
| <GearIcon /> |
| Settings |
| </button> |
| {/* ⭐⭐ 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 `<a href>` 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. */} |
| <a |
| className="shell-menu-item" |
| role="menuitem" |
| href={`#/${FEEDBACK_ROUTE}`} |
| onClick={() => setOpen(false)} |
| > |
| <BubbleIcon /> |
| Feedback |
| </a> |
| <a |
| className="shell-menu-item" |
| role="menuitem" |
| href={`#/${USAGE_ROUTE}`} |
| onClick={() => setOpen(false)} |
| > |
| <PieIcon /> |
| Usage credits |
| </a> |
| {user.tenant === NO_SUBSCRIPTION_TENANT ? null : ( |
| <a |
| className="shell-menu-item" |
| role="menuitem" |
| href={`#/${SUBSCRIPTION_ROUTE}`} |
| onClick={() => setOpen(false)} |
| > |
| <CardIcon /> |
| Subscription |
| </a> |
| )} |
| {extras.length ? <div className="shell-menu-rule" role="separator" /> : null} |
| {extras.map((p) => ( |
| <a |
| key={p.key} |
| className="shell-menu-item" |
| role="menuitem" |
| href={appLink(APP_BASE, p.key)} |
| target="_blank" |
| rel="noreferrer" |
| onClick={() => 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} |
| <ExtIcon /> |
| </a> |
| ))} |
| <div className="shell-menu-rule" role="separator" /> |
| <button |
| type="button" |
| className="shell-menu-item is-signout" |
| role="menuitem" |
| onClick={() => { |
| setOpen(false); |
| onSignOut(); |
| }} |
| > |
| <ExitIcon /> |
| Sign out |
| </button> |
| </div> |
| ) : null} |
| <button |
| type="button" |
| className="shell-account" |
| aria-haspopup="menu" |
| aria-expanded={open} |
| onClick={() => setOpen((v) => !v)} |
| > |
| {user.avatar ? ( |
| // Wave 14 C-AVATAR — the chip wears the photo. Inline-styled: `.shell-avatar`'s |
| // stylesheet is another wave-14 session's file (index.css), so the img carries |
| // its own fit rules and inherits the span's box from the class. |
| <img |
| className="shell-avatar" |
| src={user.avatar} |
| alt="" |
| aria-hidden="true" |
| style={{ objectFit: "cover", padding: 0 }} |
| /> |
| ) : ( |
| <span className="shell-avatar" aria-hidden="true">{initial}</span> |
| )} |
| <span className="shell-account-who"> |
| <span className="shell-account-name">{user.name}</span> |
| <span className="shell-account-role">{isAdmin(user) ? "Admin" : "Member"}</span> |
| </span> |
| <DotsIcon /> |
| </button> |
| </div> |
| ); |
| } |
|
|
| /** |
| * ⭐ 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 ( |
| <ErrorBoundary surface="Loopable"> |
| <ShellFrame /> |
| </ErrorBoundary> |
| ); |
| } |
|
|
| function ShellFrame() { |
| const [session, setSession] = useState<Session>({ phase: "checking" }); |
| const [nav, setNav] = useState<Nav>({ phase: "idle" }); |
| // Wave 18 (C3-UT): bumped after "+ New database" so the rail refetches and the new ut_ key |
| // appears without a reload. Wave 19 (R8/C1) adds the second and only other bumper — a |
| // rename or an icon change, for the same reason: the override lives in the SERVER's nav |
| // payload, so the rail re-asks rather than the client inventing what the answer will be. |
| // Both bumps are "the server changed, ask it again"; nothing may bump this to paint state |
| // the server does not hold — the nav stays server-driven. |
| const [navEpoch, setNavEpoch] = useState(0); |
| const [dataError, setDataError] = useState(""); |
| const [toast, setToast] = useState(""); |
| // `null` = closed. The SECTION is the open state, so "Settings" and |
| // "Users" are one component reached two ways rather than two dialogs. |
| const [settings, setSettings] = useState<SettingsSection | null>(null); |
| // ── WAVE 20 items 18/23/26 (R10, C-SHARE): the access editor, for all three kinds ── |
| // |
| // The dialog is the SHELL's, and it is opened from two places that must not import |
| // each other: this frame (a database's ⋯) and the views rail (a view's or folder's |
| // ⋯), which is host-neutral `customer-grid/` code. So the rail raises a window event |
| // — the same channel the grid already uses for toasts and staleness — and the frame, |
| // which owns modals, renders it. |
| const [shareFor, setShareFor] = useState<ShareRequest | null>(null); |
| // ── WAVE 20 item 25 (C-ALERT): the inbox lives beside the nav, not in it ────── |
| // |
| // `inbox` is held HERE rather than inside the pane because the badge outlives the |
| // pane: the count has to be on screen while the panel is shut, which is the only |
| // state in which a badge is useful at all. |
| const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX); |
| /** ⭐ W32 (ASK C-17) — the in-flight VIEW_OPEN retry ladder's canceller, if one is running. |
| * A reader who clicks a second notification while the first is still retrying would otherwise |
| * have TWO ladders racing, and the older one would yank them back to the previous view. */ |
| const viewEmitCancel = useRef<null | (() => void)>(null); |
| const route = useHashRoute(); |
| /** |
| * ⭐⭐ WAVE 35 · T06/T09 (asks B-2 and B-8) — OPEN A VIEW INSIDE ITS DATABASE. One handler, |
| * passed to BOTH `HomePage` and `StarredPage`, so a starred view opened from Home and the same |
| * view opened from Starred cannot behave differently. |
| * |
| * ⛔ THE FRAME OWNS THE HASH AND THE LADDER, WHICH IS WHY THIS IS NOT A PROP THE PAGES IMPLEMENT. |
| * `CustomerGrid`'s VIEW_OPEN listener drops an event whose view it has not fetched yet and there |
| * is NO ACK, so a single emit fired the instant the hash changes is silently discarded on a cold |
| * click: the table opens, the view is not selected, and the click "worked". |
| * |
| * ⛔ `gridScopeFor`, NEVER THE REGISTRY KEY. The listener compares `detail.topic !== scope`, |
| * and a grid's scope is a DIFFERENT SPELLING from its registry key (`customer_data` -> `customer`). |
| * A mismatch is dropped in silence — the same class of defect as the wave-34 alert-create handler |
| * that filed alerts against the wrong topic and answered 200. |
| * |
| * ⚠ THE LADDER IS LONGER THAN THE INBOX'S, AND THAT IS MEASURED, NOT CAUTIOUS. `OPEN_RETRY_MS` |
| * tops out at 2,600 ms; a grid's COLD mount was measured at ~12 s (wave 34, the Query refactor), |
| * so the inbox ladder would have every attempt land before the listener exists. A retry ladder is |
| * only correct against a measured worst case. |
| */ |
| const VIEW_OPEN_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const; |
| const openViewInDatabase = useCallback((database: string, viewId: string) => { |
| // ⛔⛔ WAVE 35 QA — THIS WROTE `#/db/${database}` AND NOTHING IN THE APP READS A `db/` PREFIX, |
| // SO EVERY STARRED-VIEW TILE ON HOME AND ON STARRED WAS A DEAD CLICK. `routeKeyOf` strips only |
| // a leading `#/` (`nav.ts`), so the route became the literal `"db/ut_odoo_invoices"`; |
| // `resolveRoute` looks that up against entry keys that are BARE (`"ut_odoo_invoices"`), missed, |
| // and the shell fell back to Home — the URL changed, the screen did not. Reproduced on two |
| // different views, held for 30 s+, and confirmed structurally: `"db/"` appeared exactly ONCE |
| // in `Shell.tsx` + `nav.ts` + `dbFrame.tsx` combined — here, the writer, with no reader |
| // anywhere. Every other hash write in this file is `#/${key}` (`:1764`, `:2934`, …); this one |
| // invented a second vocabulary for the same thing. [[one-question-two-normalizers]] |
| // ⚠ The failure is silent by construction, which is why no gate saw it: a route that does not |
| // resolve is the SAME code path as "no route yet", and that path renders Home on purpose. |
| window.location.hash = `#/${database}`; |
| viewEmitCancel.current?.(); |
| viewEmitCancel.current = retryEmit( |
| () => { |
| signal(VIEW_OPEN_EVENT, { topic: gridScopeFor(database), viewId }); |
| }, |
| undefined, |
| VIEW_OPEN_RETRY_MS |
| ); |
| }, []); |
| // WAVE 23 C9 — non-null on `#/form/<token>` only. Computed here rather than inside the render |
| // branch because the hash normaliser below has to read it too, and two copies of "is this the |
| // public form?" is how one of them ends up answering differently. |
| const formToken = formTokenOf(route); |
| // ⭐⭐ W33-T26 — the published-view token, read the same way and for the same reason the line |
| // above gives: two copies of "is this a public route?" is how one of them ends up answering |
| // differently. Both feed the SAME auth-bounce exemption and the same pre-session render branch. |
| const publishToken = publishTokenOf(route); |
| const publicToken = formToken || publishToken; |
| // Owner items 10/11 — the navigation folds to a slim strip: by the toggle in the rail head, |
| // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the |
| // grid). Remembered per browser; expanding is always one click on the same toggle. |
| const [navCollapsed, setNavCollapsed] = useState<boolean>(() => { |
| try { |
| return localStorage.getItem("aios-nav-collapsed") === "1"; |
| } catch { |
| return false; |
| } |
| }); |
| useEffect(() => { |
| try { |
| localStorage.setItem("aios-nav-collapsed", navCollapsed ? "1" : "0"); |
| } catch { |
| // storage can be blocked; the toggle still works for the session |
| } |
| }, [navCollapsed]); |
| // ── ⭐⭐ W34-T12 (ruling R1): AN EXPAND HAS TO SURVIVE THE NEXT CLICK ────────────────────── |
| // |
| // R1: *"Fix the navigation error where minimizing gets stuck and cannot be reopened."* |
| // |
| // ⛔ THE CAUSE IS NOT A MISSING CONTROL, AND THAT WAS WORTH PROVING BEFORE CHANGING ANYTHING |
| // (`proto/nav-stuck/README.md` records the trace). Every one of the six `setNavCollapsed` call |
| // sites was read; the brand button below is rendered unconditionally, is enabled exactly when |
| // the rail is collapsed, and no `.is-collapsed` rule hides it. There is no state with no way |
| // back. What there is: `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and |
| // EVERY view switch, unconditionally and undebounced (owner item 10, 2026-07-31 — "I am working |
| // now"), and the listener always SETS. So the user clicks the logo, the rail opens, they touch |
| // one cell, and it shuts again. The control works; its EFFECT never survives. From the seat that |
| // is "cannot be reopened", and no gate can see it because the code does exactly what it was |
| // asked to do in July. |
| // |
| // ⚠ SO KEEP THE FOLD AND MAKE THE EXPAND STICK, rather than revoking owner item 10. This ref is |
| // raised by every DELIBERATE expand and lowered by the explicit Minimize button; the automatic |
| // fold declines while it is up. |
| // |
| // ⛔ NOT PERSISTED, DELIBERATELY. `navCollapsed` rides localStorage; this does not. Persisting it |
| // would retire owner item 10 permanently the first time anybody clicked the logo — a ruling |
| // revoked by a preference. A reload starts it down, so the first fold still happens per visit. |
| const railHeldOpen = useRef(false); |
| /** Every deliberate expand goes through here, so a fifth expand door cannot forget the flag. */ |
| const expandRail = useCallback(() => { |
| railHeldOpen.current = true; |
| setNavCollapsed(false); |
| }, []); |
| /** Asking for the fold is also asking for the AUTOMATIC fold back. */ |
| const collapseRail = useCallback(() => { |
| railHeldOpen.current = false; |
| setNavCollapsed(true); |
| }, []); |
| // Owner item 3 (2026-07-31) — the collapsed strip names its icons on hover. A real DOM |
| // tooltip positioned FIXED beside the rail: the rail scrolls and clips (overflow), so a |
| // CSS ::after inside it could never escape; and it is pointer-events:none by standing rule |
| // ([[ui-invisible-to-assertions]] — a tooltip that can receive the pointer swallows the |
| // next click). |
| const [navTip, setNavTip] = useState<{ label: string; y: number } | null>(null); |
| useEffect(() => { |
| if (!navCollapsed) setNavTip(null); |
| }, [navCollapsed]); |
| const tipEnter = useCallback( |
| (label: string) => (e: ReactMouseEvent<HTMLElement>) => { |
| const r = e.currentTarget.getBoundingClientRect(); |
| setNavTip({ label, y: r.top + r.height / 2 }); |
| }, |
| [] |
| ); |
| const tipLeave = useCallback(() => setNavTip(null), []); |
| // ── WAVE 20 item 7 (R9): the collapsed strip expands from its own BACKGROUND ── |
| // |
| // Until now the 56px strip had exactly one way back — the logo — and the owner |
| // kept clicking the empty space beside an icon, which did nothing at all. R9: |
| // "clicking the collapsed strip's blank/background area EXPANDS it; page icons |
| // keep navigating directly." |
| // |
| // ⚠ THE TEST IS THE TARGET, NOT THE CURRENT ELEMENT. `e.currentTarget === e.target` |
| // would only fire on the aside's own few pixels of padding — every gap the user |
| // actually clicks belongs to `.shell-nav` or `.shell-nav-list`, which are not |
| // interactive but ARE elements. So the rule is inverted: a click that landed on |
| // (or inside) a control does what that control does; anything else is background. |
| // ⛔ `closest` and not a tag check: the click usually lands on the `<svg>` or the |
| // `<span>` INSIDE the link, so testing the target's own tagName would treat every |
| // icon click as background and swallow the navigation R9 explicitly preserves. |
| // ⚠ W34-T12 — the SELECTOR LIST IS UNTOUCHED, and that is the ticket's named trap: widening it |
| // so more clicks expand would trade one outage for another (a collapsed row's icon could no |
| // longer be clicked without the rail jumping open under the pointer). Only the setter changed. |
| const expandOnBlank = useCallback((e: ReactMouseEvent<HTMLElement>) => { |
| const hit = e.target as Element | null; |
| if (hit?.closest?.('a,button,input,textarea,select,[role="menuitem"],[role="dialog"]')) return; |
| expandRail(); |
| }, [expandRail]); |
|
|
| // ── WAVE 23 items 9/10 (R7/R8, contract C10): the DATABASE FLYOUT ────────────────────────── |
| // |
| // R7 takes the databases OUT of the rail: the always-on `.shell-nav-list` band becomes a panel |
| // behind a "Database" button, so the rail is a fixed set of destinations instead of a list |
| // whose height is a function of how many tables the tenant made. |
| // |
| // `dbAt` is the anchor rect (null = shut), `dbQuery` the search. The panel renders OUTSIDE the |
| // `<aside>` and `position: fixed`, for the same reason `shell-nav-tip` does: the rail scrolls |
| // and clips, so anything that must escape it cannot be its descendant. |
| const [dbAt, setDbAt] = useState<{ x: number; y: number } | null>(null); |
| const [dbQuery, setDbQuery] = useState(""); |
| const dbPanel = useRef<HTMLDivElement | null>(null); |
| const dbButton = useRef<HTMLButtonElement | null>(null); |
| const closeDbFly = useCallback(() => { |
| setDbAt(null); |
| setDbQuery(""); |
| }, []); |
| // ⚠ Declared here and READ inside the close effect below, because that effect must not |
| // re-subscribe on every drag state change — and because the guard it needs is "a drag is in |
| // flight", which is state this component already holds (`dragKey`, declared further down with |
| // the other prefs state). A ref keeps the effect's dependency list honest. |
| const draggingRef = useRef(false); |
|
|
| /** |
| * ⛔ THREE WAYS OUT, AND ONE THING THAT MUST NOT CLOSE IT. |
| * |
| * Out: an outside mousedown, Escape, or a database link (handled at the link — a click on the |
| * row's ⋯ must NOT close the panel the menu is anchored inside). |
| * |
| * NOT out: a DRAG. This panel carries the folder drag-and-drop the rail used to (`NAV_DRAG_TYPE`, |
| * the `__root__` drop target, `draggable` rows). A pointer that leaves the panel mid-drag would |
| * otherwise trip the outside-click rule and unmount the drop target under the cursor — the |
| * folder move silently fails and the panel vanishes, which reads as a crash. The wirings this |
| * wave must keep green (W-3/W-5 live on these rows) would still pass, because a gate cannot |
| * see a drop that never lands. |
| * |
| * And NOT the trigger button: without that exemption the mousedown closes the panel and the |
| * button's own click immediately reopens it, so the control could never be used to shut the |
| * thing it opened. (`useMenu` in NavExtras has this quirk; a rail button is used far more often |
| * than a row's ⋯, so it is worth the ref here.) |
| */ |
| useEffect(() => { |
| if (!dbAt) return; |
| const onDown = (e: MouseEvent) => { |
| if (draggingRef.current) return; |
| const t = e.target as Node | null; |
| if (dbPanel.current?.contains(t as Node)) return; |
| if (dbButton.current?.contains(t as Node)) return; |
| closeDbFly(); |
| }; |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key === "Escape") closeDbFly(); |
| }; |
| document.addEventListener("mousedown", onDown, true); |
| document.addEventListener("keydown", onKey, true); |
| return () => { |
| document.removeEventListener("mousedown", onDown, true); |
| document.removeEventListener("keydown", onKey, true); |
| }; |
| }, [dbAt, closeDbFly]); |
|
|
| // ── WAVE 23 C10: the recents stamp ──────────────────────────────────────────────────────── |
| // |
| // `opened` mirrors the stamps this session made, so Home shows the database you opened one |
| // click ago rather than the state of the world at sign-in (`mergeRecents`' own note). It is |
| // NOT persisted: a reload re-asks the server, which is the only end that knows. |
| const [opened, setOpened] = useState<Record<string, number>>({}); |
| const lastStamped = useRef(""); |
|
|
| // The window signals the data layer raises (apiContract.ts). It talks to the |
| // frame this way because `customer-grid/**` is host-neutral — the same tree |
| // the Streamlit embed ships — and must not import a shell. |
| useEffect(() => { |
| const onUnauthorized = () => setSession({ phase: "anon" }); |
| const onDataError = (e: Event) => |
| setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded."); |
| const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? "")); |
| // ⭐ W34-T12 (R1) — THE AUTOMATIC FOLD DECLINES ONCE THE USER HAS PUT THE RAIL BACK. |
| // Owner item 10's fold is intact for the first grid click of a visit; after a deliberate |
| // expand it stops firing, so the logo's effect survives the next cell click instead of being |
| // undone by it. Reading a ref here is what keeps this listener's `[]` deps honest — a state |
| // value would have to be in the dependency array, re-registering the listener on every fold. |
| const onNavMinimize = () => { |
| if (railHeldOpen.current) return; |
| setNavCollapsed(true); |
| }; |
| // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING — |
| // `parseShareRequest` fail-closes on an unknown kind rather than launching a |
| // dialog whose every save would 400. |
| const onShareOpen = (e: Event) => |
| setShareFor(parseShareRequest((e as CustomEvent).detail)); |
| window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized); |
| window.addEventListener(DATA_ERROR_EVENT, onDataError); |
| window.addEventListener(TOAST_EVENT, onToast); |
| window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize); |
| window.addEventListener(SHARE_OPEN_EVENT, onShareOpen); |
| return () => { |
| window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized); |
| window.removeEventListener(DATA_ERROR_EVENT, onDataError); |
| window.removeEventListener(TOAST_EVENT, onToast); |
| window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize); |
| window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen); |
| }; |
| }, []); |
|
|
| // The toast clears itself; nothing else depends on it having been seen. |
| useEffect(() => { |
| if (!toast) return; |
| const t = setTimeout(() => setToast(""), 6000); |
| return () => clearTimeout(t); |
| }, [toast]); |
|
|
| // DEBT D-38 (wave 22): the error card is ABOUT a surface, so leaving that surface clears |
| // it — before this, one failed fetch left the card over every module until sign-out |
| // (`setDataError("")` at the nav effect runs only on a WHO change). Clearing on the route |
| // commit cannot suppress a real failure: the failing surface raises DATA_ERROR_EVENT from |
| // its own fetch, which resolves after this effect has run. |
| useEffect(() => { |
| setDataError(""); |
| }, [route]); |
|
|
| // Boot: ask the server who we are. ⚠ Every boot, no cache — the X3 cookie is |
| // HttpOnly and revocable by epoch bump, so the server is the only thing that |
| // can answer this and a remembered answer is a stale one. |
| useEffect(() => { |
| let dead = false; |
| void me().then((user) => { |
| if (!dead) setSession(user ? { phase: "authed", user } : { phase: "anon" }); |
| }); |
| return () => { |
| dead = true; |
| }; |
| }, []); |
|
|
| // Keyed on the USERNAME, not the session object: a new object identity on |
| // every state write would re-fetch the nav for no reason. |
| const who = session.phase === "authed" ? session.user.username : null; |
|
|
| useEffect(() => { |
| if (who === null) { |
| setNav({ phase: "idle" }); |
| return; |
| } |
| let dead = false; |
| // ⚠ WAVE 19 — STALE-WHILE-REFRESH, and this line is why the icon picker works |
| // at all. This effect re-runs on every `navEpoch` bump, and it used to blank |
| // the nav to `loading` each time: `entries` reads as `NO_ENTRIES` whenever the |
| // phase is not `ready`, so the whole database list unmounted behind a spinner |
| // and remounted a round trip later. Every row's `RowMenu` went with it — |
| // taking the OPEN icon picker's state with it, so "pick a shape, then pick a |
| // tone" was impossible and every click flashed the rail. "+ Create new…" |
| // (gated on `ready`) disappeared too. |
| // |
| // It was invisible before because the only bumper was "+ New database", which |
| // navigates away in the same beat. R8 made the bump a frequent, EXPLORATORY |
| // action, which is what turned a flash into a broken control. |
| // |
| // ⛔ SAFE ACROSS ACCOUNTS, which is the only thing that could make it |
| // dangerous: an account switch always passes through `anon` (sign-out, or the |
| // 401 event), so `who` becomes null, this effect takes the branch above and |
| // sets `idle` — a stale nav can never survive into a different session's |
| // render. Same stale-while-refresh doctrine the data cache already follows. |
| setNav((cur) => (cur.phase === "ready" ? cur : { phase: "loading" })); |
| // ⚠ Clear the last session's failures. Without this, a read error survives |
| // a sign-out/sign-in and the freshly authenticated user lands on an error |
| // panel describing something that happened to somebody else's session. |
| setDataError(""); |
| setToast(""); |
| void fetchNav().then((r) => { |
| if (dead) return; |
| if (r.ok) { |
| // `chrome:'utility'` rows leave the module list — the payload has said |
| // so since X6; the Analyst slot and the account menu place them where |
| // the host does (`core.perms.nav_pages`' own contract). |
| const { main, utility } = splitChrome(r.pages); |
| setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility, |
| recents: r.recents, |
| ...(r.empty ? { empty: r.empty } : {}), |
| // ⭐ W31-T11 — carried onto the state, not dropped here. A flag parsed off the |
| // wire and discarded before render is a wiring that goes nowhere, which is |
| // exactly how wave 20 shipped four of them. |
| ...(r.omitted ? { omitted: r.omitted } : {}), |
| ...(r.degraded ? { degraded: r.degraded } : {}) }); |
| } |
| // 401 is not "the nav is broken", it is "the session died under us" — |
| // conflating them would leave a signed-out user staring at a frame. |
| else if (r.status === 401) setSession({ phase: "anon" }); |
| else setNav({ phase: "error", |
| ...(r.status === NAV_TIMEOUT_STATUS ? { timedOut: true } : {}) }); |
| }); |
| return () => { |
| dead = true; |
| }; |
| }, [who, navEpoch]); |
|
|
| /** |
| * ⭐⭐ W33-T22 — RECORD THE RAIL THE PAYLOAD JUST DESCRIBED, for the next first paint. |
| * |
| * ⚠ IT LIVES UP HERE WITH THE OTHER EFFECTS, NOT BESIDE THE ROWS IT IS ABOUT, and that is not |
| * a filing preference: the render body below has several early returns (the public form at |
| * `<FormPublic/>`, the boot gate at `session.phase === "checking"`, the login page), so a hook |
| * written next to the rail would be called on some renders and not others — the rules-of-hooks |
| * crash, and one that only fires on the public-form route. |
| * |
| * ⛔ ONLY `ready` WRITES. An `error` payload knows nothing about this account's grants, and |
| * recording "absent" from a failed read would suppress the placeholder on the next load for a |
| * row the user actually has [[empty-answer-vs-unfinished-answer]]. |
| */ |
| // NO_ENTRIES, not a fresh `[]`: a new array identity on every render would |
| // re-run the hash effect below on every render for no reason. |
| const entries = nav.phase === "ready" ? nav.entries : NO_ENTRIES; |
| // Wave 18: a fresh TENANT (server says `no_databases`) is welcomed, not warned. |
| const tenantEmptyState = |
| nav.phase === "ready" && entries.length === 0 && nav.empty === "no_databases"; |
|
|
| // ── C-SCHEMA (2026-08-02): per-user folders over the database list + the |
| // schema drawer. Placement is cosmetic per-user state — the server-filtered |
| // nav still decides what exists; prefs only arrange it. |
| const [navPrefs, setNavPrefs] = useState<NavPrefs>(EMPTY_NAV_PREFS); |
| const navPrefsRef = useRef(navPrefs); |
| navPrefsRef.current = navPrefs; |
| const [closedFolders, setClosedFolders] = useState<ReadonlySet<string>>(new Set()); |
| const [schemaFor, setSchemaFor] = useState<string | null>(null); |
| useEffect(() => { |
| if (who === null) { |
| setNavPrefs(EMPTY_NAV_PREFS); |
| setSchemaFor(null); |
| return; |
| } |
| let dead = false; |
| void fetchNavPrefs().then((p) => { |
| if (!dead) setNavPrefs(p); |
| }); |
| return () => { |
| dead = true; |
| }; |
| }, [who]); |
| // Optimistic with an honest revert: the rail moves now; a refused write puts |
| // it back and says so rather than leaving a lie on screen. |
| const commitPrefs = useCallback((next: NavPrefs) => { |
| const prev = navPrefsRef.current; |
| setNavPrefs(next); |
| void saveNavPrefs(next).then((ok) => { |
| if (!ok) { |
| setNavPrefs(prev); |
| setToast("The folder change was not saved: the store refused the write."); |
| } |
| }); |
| }, []); |
| const movePage = useCallback( |
| (key: string, folderId: string | null) => { |
| const cur = navPrefsRef.current; |
| const placement = { ...cur.placement }; |
| if (folderId) placement[key] = folderId; |
| else delete placement[key]; |
| commitPrefs({ folders: cur.folders, placement }); |
| }, |
| [commitPrefs] |
| ); |
| const renameFolder = useCallback( |
| (id: string, name: string) => { |
| const cur = navPrefsRef.current; |
| commitPrefs({ |
| folders: cur.folders.map((f) => (f.id === id ? { ...f, name } : f)), |
| placement: cur.placement, |
| }); |
| }, |
| [commitPrefs] |
| ); |
| const deleteFolder = useCallback( |
| (id: string) => { |
| const cur = navPrefsRef.current; |
| commitPrefs({ |
| folders: cur.folders.filter((f) => f.id !== id), |
| placement: Object.fromEntries( |
| Object.entries(cur.placement).filter(([, v]) => v !== id) |
| ), |
| }); |
| }, |
| [commitPrefs] |
| ); |
| const toggleFolder = useCallback((id: string) => { |
| setClosedFolders((prev) => { |
| const next = new Set(prev); |
| if (next.has(id)) next.delete(id); |
| else next.add(id); |
| return next; |
| }); |
| }, []); |
| // ── Wave 14 C-NAVFOLD (items 4/6): drag replaces the menu's Move-to-folder. A PRIVATE |
| // dataTransfer MIME so a drop anywhere text-editable types nothing (the wave-13 C-LAYOUT |
| // scar); `dropTarget` is a folder id or "__root__" (drag out = drop on the list itself). |
| const [dragKey, setDragKey] = useState<string | null>(null); |
| const [dropTarget, setDropTarget] = useState<string | null>(null); |
| // WAVE 23 C10 — read by the flyout's outside-click rule, which must NOT fire mid-drag (see |
| // its note). Assigned during render like `navPrefsRef` above: the effect keeps a stable |
| // dependency list and still sees the current answer. |
| draggingRef.current = dragKey !== null; |
| const createFolder = useCallback( |
| (name: string) => { |
| const cur = navPrefsRef.current; |
| if (cur.folders.length >= MAX_NAV_FOLDERS) { |
| setToast(`At most ${MAX_NAV_FOLDERS} folders.`); |
| return; |
| } |
| const id = `nf_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; |
| commitPrefs({ folders: [...cur.folders, { id, name }], placement: cur.placement }); |
| // ⭐ WAVE 23 C10 — SHOW THE THING THAT WAS JUST MADE. "+ Create new…" stayed in the RAIL |
| // while the folders it creates moved into the FLYOUT, so naming a folder produced no |
| // visible result at all: the only surface that draws one was a closed panel. The |
| // affordance and its effect ended up in different places, which is the class of defect |
| // this rework was supposed to remove rather than introduce. Opening the panel is the |
| // smaller of the two fixes and keeps C10's rail order exactly as ruled. |
| const at = flyoutFrom(dbButton.current); |
| if (at) setDbAt(at); |
| }, |
| [commitPrefs] |
| ); |
|
|
| // ── WAVE 19 R8 / C1: the tenant-wide name + icon overrides (`nav_meta`) ────── |
| // |
| // ⚠ NOT OPTIMISTIC, unlike `commitPrefs` two blocks up, and the asymmetry is |
| // the point. Prefs are this user's own arrangement held in this component's |
| // state, so the rail can move now and revert if the write is refused. |
| // `nav_meta` is TENANT-WIDE and lives in the SERVER's nav payload — the shell |
| // holds no copy to update. Painting a new name locally would mean rendering a |
| // label from one source while every other reader of the nav still had the old |
| // one; re-asking is one cheap round trip and it cannot disagree with itself. |
| // ── C-ALERT: the unread count, and the door that MAKES an alert ─────────────── |
| // |
| // Polled once per session boot and after every write this frame knows about — |
| // never on a timer. A nav badge that re-fetches every 30 seconds is a background |
| // request per user per minute for a number nobody is looking at; the honest |
| // refresh points are "the app just started" and "you just did something". |
| useEffect(() => { |
| if (who === null) { |
| setInbox(EMPTY_INBOX); |
| return; |
| } |
| let dead = false; |
| const pull = () => { |
| void fetchInbox().then((r) => { |
| if (!dead && r.ok) setInbox(r.value); |
| }); |
| }; |
| pull(); |
| // ...and again when the tab is looked at, which is the honest substitute for a |
| // timer: news arrives while you are elsewhere, and "elsewhere" is exactly when a |
| // poll would be wasted. One request per return to the tab, none while it sits. |
| window.addEventListener("focus", pull); |
| return () => { |
| dead = true; |
| window.removeEventListener("focus", pull); |
| }; |
| }, [who]); |
|
|
| // The rail asks for an alert on a view; the FRAME answers, because the rail does |
| // not know its own topic — it holds views, and the scope key is the route's. |
| useEffect(() => { |
| const onCreate = (e: Event) => { |
| const req = parseAlertCreate((e as CustomEvent).detail); |
| if (!req) return; |
| // ⛔ THE GRID'S OWN SCOPE, NOT THE HASH (2026-08-14). This used to map the route key |
| // inline — a third copy of `gridScopeFor`, and one the Query module breaks outright: |
| // a grid mounted at `#/query` would have filed every alert raised from it against |
| // `customer`, because "query" is not a database key and the else-branch is the |
| // customer topic. Silently wrong, on a door that answers 200. `currentSurfaceScope()` |
| // is the value the emitting grid stamps on all its other events, read back. |
| const topic = currentSurfaceScope(); |
| void createAlert(req.viewId, topic, req.label).then((r) => { |
| if (!r.ok) { |
| // ⚠ The 400 `no_filter` is a real answer and rides through verbatim: a |
| // view with no active filter matches everything, so an alert on it could |
| // never see an entrant. "Refused, and here is why" beats a dead bell. |
| setToast(r.message); |
| return; |
| } |
| // ⭐ W34-T17 (R23) — THE COPY NAMES **INBOX**, because that is the row the reader will |
| // look for. Owner, 2026-08-16: *"Alerts is called Inbox and it's right below Home."* This |
| // sentence used to send them to "Alerts", a surface that no longer has that name anywhere |
| // on screen: the rail row says Inbox, the route is `#/inbox`, the page heading says Inbox. |
| // A toast is a door with a sign on it, and the sign was pointing at a room that was |
| // renamed three waves ago. |
| setToast(`Alerting on "${req.label}". New records that enter it appear in your Inbox.`); |
| void fetchInbox().then((got) => { |
| if (got.ok) setInbox(got.value); |
| }); |
| }); |
| }; |
| window.addEventListener(ALERT_CREATE_EVENT, onCreate); |
| return () => window.removeEventListener(ALERT_CREATE_EVENT, onCreate); |
| }, []); |
|
|
| const commitNavMeta = useCallback(async (key: string, patch: NavMetaPatch) => { |
| const ok = await saveNavMeta(key, patch); |
| if (ok) setNavEpoch((e) => e + 1); |
| else setToast("That change was not saved: the store refused the write."); |
| }, []); |
|
|
| /** |
| * ⭐ WAVE 21 item 6 (R3, contract C3) — delete a user database, and then leave. |
| * |
| * The confirm face owns the QUESTION; this owns the round trip and the two |
| * things that have to happen after a yes: |
| * |
| * · the nav is refetched (`navEpoch`), because the row must go — and because |
| * the server is the only end that knows what else went with it; |
| * · if the deleted table is the one on screen, the route moves. The hash |
| * would otherwise still name a key the payload no longer carries, and |
| * `resolveRoute` answers that with a blank main pane — a shell that looks |
| * broken as the reward for a successful delete. |
| * |
| * The route change is deliberately NOT optimistic. It fires after the server |
| * confirms, so a refused delete leaves the user exactly where they were, still |
| * looking at the table they asked about. |
| */ |
| const removeDatabase = useCallback( |
| async (key: string): Promise<{ ok: boolean; error?: string }> => { |
| const r = await deleteTable(key); |
| if (!r.ok) return r; |
| // The grid's cached rows for THIS table die with it: a remount inside the |
| // cache window would otherwise re-paint a table that no longer exists. |
| clearCustomersCache(); |
| setNavEpoch((e) => e + 1); |
| setToast("Database deleted."); |
| // An EMPTY hash, not a computed one: `resolveRoute` answers "" with |
| // `defaultRoute(entries)`, so the landing surface is chosen by the same |
| // rule a fresh sign-in uses — and it is chosen from the REFETCHED nav, |
| // which a key computed here (from the list still holding the dead row) |
| // could not be. |
| if (route === key) window.location.hash = ""; |
| return { ok: true }; |
| }, |
| [route] |
| ); |
|
|
| // Keep the URL honest in both directions: an anonymous shell sits on |
| // `#/login`, and a signed-in one never does. |
| // |
| // ⭐ WAVE 23 C10 — THE LANDING MOVED, AND ALL THREE SITES MOVED TOGETHER. `LANDING_PREFERENCE` |
| // and `defaultRoute` are the other two (nav.ts); this is the redirect a fresh sign-in takes. |
| // C10's own warning is that a half-move strands logins on a dead hash, so: |
| // |
| // · the `entries.length` gate is GONE. It existed because `defaultRoute([])` was `""` — a |
| // redirect to `#/` — so the frame had to wait for the nav before it could name a landing. |
| // `home` is chrome and needs no entry, so there is nothing left to wait for, and the |
| // tenant whose nav is legitimately empty (a fresh workspace, wave 18) now lands somewhere |
| // real instead of sitting on the login hash until a database exists. |
| // · an EMPTY hash normalises to the landing too. `removeDatabase` sets `hash = ""` after a |
| // delete and relies on this; before, an empty hash silently rendered `customer_data` while |
| // the URL said nothing. |
| useEffect(() => { |
| // ⛔ WAVE 23 C9 — THE PUBLIC FORM IS EXEMPT FROM BOTH DIRECTIONS. Without the first |
| // exemption an anonymous visitor at `#/form/<token>` is bounced to `#/login` before the |
| // render branch is ever reached, and the public door is public only to people who already |
| // have an account. Without the second, a SIGNED-IN person testing their own form link is |
| // yanked to Home mid-read. |
| // ⭐⭐ W33-T26 — `publicToken`, not `formToken`. Wave 33's published view (`#/v/<token>`) needs |
| // BOTH exemptions for exactly the reasons the paragraph above gives about the form, and |
| // extending the existing test is the only way they cannot come apart: a second `if` here |
| // would be a second answer to "is this route public", and the wave-23 note is about what |
| // happens when one of two such answers is forgotten. |
| if (publicToken) return; |
| if (session.phase === "anon" && route !== LOGIN_ROUTE) { |
| window.location.hash = `#/${LOGIN_ROUTE}`; |
| } else if (session.phase === "authed" && (route === LOGIN_ROUTE || route === "")) { |
| window.location.hash = `#/${defaultRoute(entries)}`; |
| } |
| }, [session.phase, route, entries, publicToken]); |
|
|
| // ── WAVE 24 item 13 (R10, wiring W24-W3): Home's automations ──────────────────────────── |
| // |
| // ⚠ FETCHED HERE, NOT IN `HomePage`. Home holds no state of its own by contract — its whole |
| // C10 argument for being CHROME rather than a granted surface is that it draws only what the |
| // frame already has. A fetch inside it would make it a surface with a data contract. |
| // |
| // ⛔ A 403 BECOMES `[]`, NOT AN ERROR. An account without the automation grant is not a |
| // failure — it is an account that has no automations to show, and the section simply does not |
| // render. Raising DATA_ERROR_EVENT here would put an error card over Home for every member of |
| // a tenant whose automations they were never granted. |
| // |
| // ⚠ RE-ASKED ON `navEpoch`, so Home does not go stale behind a write it did not make: creating |
| // or deleting a database bumps the nav, and a deleted database's automations are paused and |
| // re-stamped by the same route (C3's footprint contract), so their tiles have to be re-read. |
| // ⚠ WAVE 25 (R8): this used to say `navEpoch` "is what makes create an automated database |
| // land". That door is deleted, and the epoch bump has never been what refreshed THIS list |
| // anyway — `createAndOpen` happens on the automation surface, which reloads its own list. |
| const [autoTiles, setAutoTiles] = useState<AutomationTile[]>([]); |
| /** |
| * ⭐⭐ WAVE 35 · T08 (owner item 11, wiring W6) — Home's agent tiles stop going stale. |
| * The frame fetches these tiles; `AutomationSurface` is props-free by contract and fetches its |
| * own list, so nothing told the frame when a delete happened inside the module. It listens now. |
| * ⚠ A SEPARATE EPOCH FROM `navEpoch` ON PURPOSE: bumping `navEpoch` would re-fetch `/nav`, a |
| * whole-document read of a 28.6 MB store (D-175/D-185), to refresh a list of tiles. |
| * ⚠ The DATABASE half of item 11 needed no change and that was CHECKED, not assumed: |
| * `removeDatabase` already bumps `navEpoch`, and this effect already depends on it. |
| */ |
| const [agentsEpoch, setAgentsEpoch] = useState(0); |
| useEffect(() => { |
| const onChanged = () => setAgentsEpoch((e) => e + 1); |
| window.addEventListener(AGENTS_CHANGED, onChanged); |
| return () => window.removeEventListener(AGENTS_CHANGED, onChanged); |
| }, []); |
| useEffect(() => { |
| if (who === null) { |
| setAutoTiles([]); |
| return; |
| } |
| const ac = new AbortController(); |
| let dead = false; |
| void listAutomations(ac.signal) |
| .then((r) => { |
| if (dead) return; |
| setAutoTiles( |
| (r.automations || []).map((a) => ({ |
| id: a.id, |
| name: a.name, |
| /* ⭐ WAVE 26 · ITEM 18 (R14) — `state: stateOf(a)` is GONE with Home's status dot. |
| `AutomationTile` dropped the field, so leaving the call here would be a computed |
| fact with no reader. `stateOf` itself stays exported and alive in `automationApi` |
| (the automation module still decides "is this running" for its own surfaces) — this |
| is one CONSUMER leaving, not the definition. */ |
| // The rail's own second line, one fact not two: the forward-looking one when a |
| // schedule exists (it answers "does this run itself?"), the last run otherwise. |
| sub: a.schedule?.enabled |
| ? a.nextRunAt |
| ? `Next ${a.nextRunAt}` |
| : "Scheduled" |
| : a.status?.lastRunAt |
| ? `Last run ${a.status.lastRunAt.replace("T", " ").slice(0, 16)}` |
| : "Manual only", |
| })) |
| ); |
| }) |
| .catch(() => { |
| if (!dead) setAutoTiles([]); |
| }); |
| return () => { |
| dead = true; |
| ac.abort(); |
| }; |
| }, [who, navEpoch, agentsEpoch]); |
|
|
| // WAVE 23 C10 — stamp the route as opened, for Home's recents. |
| // |
| // ⚠ ABOVE THE EARLY RETURNS with every other hook (the React #310 scar at the block below), |
| // so it re-resolves the route itself rather than reading the `active` computed after them. |
| // |
| // THREE THINGS IT DELIBERATELY DOES NOT STAMP: a chrome route (Home and Connectors resolve to |
| // no entry, so `hit` is undefined); a route the nav has not answered for yet (same reason — |
| // `entries` is empty in flight, and stamping an unresolved key would record a page that may |
| // not exist); and a hand-off, which opens the current application in another tab and is not a |
| // surface this shell can put a recents tile back into. |
| // |
| // `lastStamped` suppresses the REPEAT write a `navEpoch` bump would otherwise cause (new |
| // `entries` identity, same route) — and clears whenever the route resolves to nothing, so |
| // leaving a table and coming back to it does re-stamp, which is what makes the ordering on |
| // Home mean "most recently opened". |
| useEffect(() => { |
| if (session.phase !== "authed") return; |
| const hit = resolveRoute(entries, route); |
| if (!hit || hit.kind !== "native") { |
| lastStamped.current = ""; |
| return; |
| } |
| if (lastStamped.current === hit.key) return; |
| lastStamped.current = hit.key; |
| postOpened(hit.key); |
| setOpened((cur) => ({ ...cur, [hit.key]: Math.floor(Date.now() / 1000) })); |
| }, [session.phase, route, entries]); |
|
|
| const signOut = useCallback(() => { |
| // The cached customer payload dies with the session: the next sign-in on |
| // this browser may be a different account, and a cached book crossing that |
| // boundary would be the cross-user leak EXIT-3b exists to prevent. |
| clearCustomersCache(); |
| void logout().then(() => setSession({ phase: "anon" })); |
| }, []); |
|
|
| // ── Wave 18 hooks — ⛔ ABOVE the early returns, or they run conditionally. The first |
| // version of this block sat below the `anon` return and blanked the whole app with React |
| // error #310 on the first authed render (found by SESSION B's probe, booked in the wave |
| // doc): the anon render mounted N hooks, the authed render mounted N+5, and React refuses |
| // a component whose hook count changes between renders. Every hook in this component stays |
| // above `if (session.phase …) return`, whatever lands here next wave. |
| // (owner item 3): the assistant's under-construction note. |
| // ⭐ WAVE 24 item 14 — it carries WHICH boarded door was knocked on, not just "open". Two |
| // surfaces are now under construction (the assistant, and Home's templates card) and they are |
| // the same dialog with a different sentence — a second near-identical modal would be the |
| // "variety is a defect" failure, and a shared one that could not say which door you came |
| // through would tell a reader about the wrong feature. |
| /** ⭐ 2026-08-14 — `"assistant"` LEFT THIS UNION. The assistant is a route now, so the only |
| * boarded door left behind this state is Templates. Narrowing the type rather than leaving the |
| * dead member is what makes `setAssistOpen("assistant")` a compile error instead of a screen |
| * nobody meant to be reachable. */ |
| const [assistOpen, setAssistOpen] = useState<false | "templates">(false); |
| // ⛔ ESCAPE CLOSES A MODAL, or its scrim becomes a trap. The assistant note shipped with only |
| // a scrim-click and a Close button, and the first close-out probe to press Escape found every |
| // subsequent click swallowed by an invisible full-screen div — the |
| // painted-but-unclickable failure [[ui-invisible-to-assertions]] names, in reverse. The |
| // new-database dialog had Escape only while its INPUT held focus, which is not the same thing. |
| useEffect(() => { |
| if (!assistOpen && !newDbOpenRef.current) return; |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key !== "Escape") return; |
| setAssistOpen(false); |
| setNewDb((cur) => (cur && cur.busy ? cur : null)); |
| }; |
| window.addEventListener("keydown", onKey); |
| return () => window.removeEventListener("keydown", onKey); |
| }); |
| // (C3-UT): "+ New database" + the user-table Add-record bar. |
| // |
| // WAVE 23 C10 — the dialog grew a `mode`: blank, or from a template. |
| // |
| // ⭐ WAVE 25 (R8) — IT WAS A THREE-WAY CHOICE AND THE THIRD ANSWER IS DELETED. The chooser's |
| // third row ("Automated") never selected a mode at all: it closed the dialog and handed off to |
| // the automation surface, which is why the union has only ever had two members. So this type is |
| // unchanged and the ROW is what goes — the tell that the third option was never really a way of |
| // creating a database, which is exactly what R8 rules. |
| const [newDb, setNewDb] = useState<null | { |
| mode: "blank" | "template"; |
| name: string; |
| busy: boolean; |
| err: string; |
| }>(null); |
| const newDbOpenRef = useRef(false); |
| newDbOpenRef.current = newDb !== null; |
| const openNewDb = useCallback((mode: "blank" | "template") => { |
| setNewDb({ mode, name: "", busy: false, err: "" }); |
| }, []); |
| /* |
| * ⛔ `openAutomated` STOOD HERE AND IS DELETED WITH ITS THREE DOORS (wave 25 item 5a, R8). |
| * |
| * It closed the dialog, routed to `#/automation` and signalled `AUTOMATION_CREATE_EVENT` — and |
| * it was that event's ONLY signaller, which is why the constant and its listener go in the same |
| * change (see the tombstones in `apiContract.ts` and `AutomationSurface.tsx`). Leaving either |
| * side behind rebuilds the wave-24 defect from the opposite end. |
| */ |
| /** |
| * ⭐ WAVE-27 item 19 (owner ruling R15) — WHAT A HAND-MADE DATABASE STARTS WITH. |
| * |
| * `user_tables.create()` mints exactly ONE column (`Name`) and zero rows, so a new database |
| * opened as a single blank column with nothing in it — a surface with no shape to copy. R15 |
| * seeds four columns and three blank records instead. |
| * |
| * ⛔ IT LIVES AT THIS DOOR, NOT IN `create()`, AND THAT IS THE RULING'S OWN SCOPE. R15 is |
| * "hand-created databases only; preset/automation spawns untouched", and `source` cannot |
| * express that: MEASURED, `automation_engine.py:1983` mints a database for the automation |
| * builder's "+ New database" through the same `create()` with the DEFAULT source, i.e. `Blank`. |
| * Seeding inside `create()` would therefore put four columns and three empty rows in front of |
| * every automation target as well — and `_vestigial_name_field` (the wave-26 migration) is |
| * written against `create()` minting EXACTLY ONE column, so it would start reading the seed as |
| * somebody's work. This dialog is the only door a person names a database at. |
| * |
| * ⛔ `status` SHIPS WITH ITS OPTIONS. A select declaring none is the W26 item-24 defect |
| * verbatim — `if ([])` is truthy, so a panel hands back an empty list as an ANSWER and the |
| * filter reads as dead — and `aios_grid` separately degrades an option-less select to text on |
| * read, so the column would not even stay a select. |
| * |
| * ⛔ `name` DECLARES `pinned`. D-80 is exactly this key going unwritten: the primary column |
| * resolves as `fields.find(f => f.pinned) ?? fields[0]`, and because nothing ever set the flag, |
| * "whichever column was created first" WAS the rule. With four columns arriving at once, |
| * leaving it implicit stops being harmless. |
| */ |
| const SEED_FIELDS = [ |
| { key: "name", label: "Name", type: "text", pinned: true }, |
| { key: "status", label: "Status", type: "select", |
| options: ["Todo", "In progress", "Done"] }, |
| { key: "assignee", label: "Assignee", type: "user" }, |
| { key: "date", label: "Date", type: "date" }, |
| ]; |
| const SEED_ROWS = 3; |
| const createDb = useCallback(async () => { |
| setNewDb((cur) => { |
| if (!cur || cur.busy || !cur.name.trim()) return cur; |
| const name = cur.name; |
| void (async () => { |
| try { |
| // WAVE 23 C10 — `source` rides the body the route ALREADY forwards |
| // (`routes_tables.py:192` passes it to `user_tables.create`). It is the literal |
| // `"Blank"` because that is the enum member the server accepts; ⚠ an unknown source is |
| // SILENTLY DOWNGRADED to Blank there (user_tables.py:165), so a third literal invented |
| // here would create a table that quietly disagrees with the word the dialog used. |
| // ⚠ "Automated" is not one of these values and never was. The engine stamps that source |
| // itself when an automation spawns its own target — which is now the ONLY way a |
| // database becomes automated, R8 having deleted the doors that implied otherwise. |
| const res = await fetch(`${API_V1}/tables`, { |
| method: "POST", |
| credentials: CREDENTIALS, |
| headers: { "Content-Type": "application/json" }, |
| // R15's four columns ride the SAME body the route already forwards to |
| // `user_tables.create(fields=...)` — no new endpoint, no server change. |
| body: JSON.stringify({ label: name.trim(), source: "Blank", fields: SEED_FIELDS }), |
| }); |
| const body = (await res.json().catch(() => null)) as |
| | { key?: string; error?: { message?: string } } |
| | null; |
| if (!res.ok || !body?.key) { |
| setNewDb({ mode: "blank", name, busy: false, |
| err: body?.error?.message || `The server answered ${res.status}.` }); |
| return; |
| } |
| // R15's three blank records, through the ORDINARY row door the grid's own "+" uses. |
| // |
| // ⚠ SEQUENTIAL, not `Promise.all`: three concurrent writes race the same store |
| // document, and the last read-modify-write would win with two rows lost. |
| // |
| // ⚠ FAILURE HERE DOES NOT FAIL THE CREATE, and that asymmetry is deliberate. The |
| // database and its four columns already exist; reporting "could not create it" over a |
| // missing blank row would be false, and rolling the database back would destroy the |
| // part that worked. A database that opens with two blank rows instead of three is not |
| // a state anybody can be harmed by — the grid's "+" adds the third. |
| for (let i = 0; i < SEED_ROWS; i += 1) { |
| try { |
| await fetch(`${API_V1}/tables/${body.key}/rows`, { |
| method: "POST", |
| credentials: CREDENTIALS, |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ values: {} }), |
| }); |
| } catch { |
| break; // the store is unreachable; the next two would fail the same way |
| } |
| } |
| setNewDb(null); |
| setNavEpoch((e) => e + 1); |
| window.location.hash = `#/${body.key}`; |
| } catch { |
| setNewDb({ mode: "blank", name, busy: false, err: "Cannot reach the server." }); |
| } |
| })(); |
| return { ...cur, busy: true, err: "" }; |
| }); |
| }, []); |
| // ⛔ `addRecord` LIVED HERE AND IS DELETED WITH THE BAR THAT CALLED IT (wave 20 item 4, |
| // R8 / C-ADDROW). The shell was POSTing `/tables/{key}/rows` itself, clearing the grid's |
| // cache and firing ROWS_STALE_EVENT to make the new row appear — a write path owned by |
| // the frame, for a surface the grid owns. R8 moves the affordance into the grid as a |
| // trailing "+" row (S3's half), where the row it adds is the row you are looking at, so |
| // the shell stops being a second writer of table data. |
|
|
| // ⭐ WAVE 23 C9 (SESSION D's page, E's mount — wiring W23-W2) — THE PUBLIC FORM, BEFORE THE |
| // AUTH WALL. |
| // |
| // ⛔ THE POSITION IS THE ENTIRE CONTRACT. It sits above BOTH early returns: above `anon`, |
| // obviously, but also above `checking` — a person filling in a public form should not watch a |
| // brand mark while `me()` resolves a session they do not have. It is below every hook, which |
| // is the other half of the rule (the React #310 scar the block at :828 documents): the hook |
| // count may not change between renders, and this branch would be the shortest possible path |
| // to breaking that. |
| // |
| // ⚠ THE SMALLEST POSSIBLE BRANCH, deliberately (C9's own words). It reads a token out of the |
| // hash and renders D's page; it does not fetch, does not touch the session, does not import |
| // anything of the grid's. Nothing else in this frame is reachable from it. |
| // |
| // ⛔ AND THE HASH NORMALISER HAS TO KNOW. The effect at :812 pins an anonymous shell to |
| // `#/login` — which would have bounced every anonymous form visitor to a login screen before |
| // this branch ever rendered. `isPublicForm` is read there too; a mount without that exemption |
| // is a public door that only signed-in people can reach. |
| // |
| // ⚠ The SERVER side of this is genuinely public (D's `routes_forms.py`, the automation-hook |
| // pattern — uniform 403, constant-time compare). This branch adds no new public SURFACE: the |
| // SPA bundle is already served unauthenticated (main.py:257-295, `_DEV_FIXTURES` guard), so |
| // `#/form/<token>` has always loaded for free. What it adds is a client route that renders |
| // something there instead of the login page. |
| if (formToken) { |
| return <FormPublic token={formToken} />; |
| } |
|
|
| // ⭐⭐ W33-T26 (item 8b, R5) — THE PUBLISHED VIEW, on the same seam and by the same argument as |
| // the form above: this branch adds no new public SURFACE. The SPA bundle is already served |
| // unauthenticated, so `#/v/<token>` has always loaded for free; what this adds is a client |
| // route that renders something there instead of the login page. The genuinely public half is |
| // the server's (`routes_publish.py` — one uniform 403, constant-time compare, a streamed body |
| // bound, a per-IP sliding window). |
| // ⚠ It returns BEFORE the boot gate below, so a stranger never sees the shell's brand card |
| // flash while `/me` decides they are nobody. |
| if (publishToken) { |
| return <PublishedView token={publishToken} />; |
| } |
|
|
| if (session.phase === "checking") { |
| // Deliberately wordless. A "Loading…" line here would flash for one round |
| // trip on every load, and the mark says everything the moment needs. |
| return ( |
| <div className="shell-boot"> |
| <Brand size={44} className="login-brand" /> |
| </div> |
| ); |
| } |
|
|
| if (session.phase === "anon") { |
| return ( |
| <LoginPage |
| onSignedIn={(user) => { |
| // Same rule as sign-out, from the other side: a fresh session never |
| // reads the previous session's cached rows. |
| clearCustomersCache(); |
| setSession({ phase: "authed", user }); |
| }} |
| /> |
| ); |
| } |
|
|
| const active = resolveRoute(entries, route); |
| const utility = nav.phase === "ready" ? nav.utility : NO_UTILITY; |
| const analyst = findAnalyst(utility); |
| // ── WAVE 19 R10 / C3: Automation LEAVES THE DATABASE LIST ──────────────────── |
| // |
| // Strategy A, chosen at the grill: a CLIENT-SIDE relocation. The registry row, |
| // `NATIVE_KEYS`, the API payload and the `#/automation` render branch below are |
| // all untouched — Automation is still a granted surface the server decides on, |
| // it simply stops being drawn among the databases, because it is not one. |
| // |
| // ⚠ THE FILTER FEEDS `foldNav`, IT IS NOT APPLIED INSIDE THE `.map()`, and the |
| // difference is a bug you would find weeks later. `foldNav` counts a folder's |
| // members from `prefs.placement`, so a user who had already dragged Automation |
| // into a folder would get a folder head reading "1" with nothing underneath it |
| // — the count derived from a list that still had the row, the rows drawn from |
| // one that did not. |
| // |
| // ⛔ AND `entries` STAYS WHOLE. `resolveRoute`, `active`, `defaultRoute`, the |
| // empty-nav note and `tenantEmptyState` all read it: filtering there would |
| // break the hash route this button navigates to, and would tell a tenant whose |
| // only granted surface is Automation that it has no surfaces at all. |
| // |
| // ⭐ WAVE 25 (D-54) — THE FILTER IS NO LONGER WRITTEN HERE. It was |
| // `entries.filter(e => e.key !== "automation")`, i.e. a local expression every consumer had to |
| // remember, and Home did not (it drew the Automation surface a tile under "Databases"). The |
| // split is a PROPERTY of the row now (`shapeNav` stamps `surface`) and `databaseEntries` is the |
| // one accessor — which also returns the BRANDED `DatabaseEntry[]`, so handing a database |
| // surface the raw `entries` is a compile error rather than one extra tile nobody looks at. |
| const automation = findAutomation(entries); |
| /** |
| * ⭐⭐ W31-T11 (owner item 6b) — WHAT TO DRAW WHERE AUTOMATION GOES WHEN IT IS NOT THERE. |
| * |
| * ⛔ THE DEFECT, in one line: `{automation ? … : null}` renders **literally nothing** for |
| * three completely different situations, and the reader cannot tell them apart: |
| * 1. `/nav` is in flight — `entries` is `NO_ENTRIES` until `phase === "ready"`, so the rail |
| * shows its four static rows and a complete-looking gap. `/nav` measured a **9.5–14.0 s |
| * band** on tenant #0, and `reference/ERROR 5.png` is exactly this screen. The owner read |
| * it as the module disappearing, and reported it across several waves. |
| * 2. the payload came back and this workspace's catalogue does not include the module — |
| * deliberate, and the rail should stay quiet. |
| * 3. the payload came back INCOMPLETE (`degraded`) or never came back at all — not |
| * deliberate, and silence is a lie about what exists. |
| * ⚠ ONLY CASE 2 DRAWS NOTHING. The other two get the slot, so an absence is never mistaken for |
| * a workspace that simply does not have the feature. (The rail already had a region-level |
| * spinner and error line four rows below — far enough away that nothing connected them to this |
| * gap, which is why "there is a loading state" was true and did not help.) |
| */ |
| /** |
| * ⭐⭐ W33-T22 (owner item 1, "I ask you fix this in multiple different waves already") — |
| * THE RULE ABOVE IS NOW A FUNCTION, BECAUSE IT HAD TO APPLY TO A SECOND ROW. |
| * |
| * ⛔ THE MECHANISM IS A RENDER GATE, NOT A DURATION, and that is why two shipped LATENCY fixes |
| * did not touch the symptom. `/nav` was 11.0 s (wave 30) and is 26 ms (wave 32) — the rail still |
| * painted twice, because `:1437` releases the shell as soon as `/me` resolves and `:911` makes |
| * `entries` `NO_ENTRIES` until `nav.phase === "ready"`. First paint therefore happens while the |
| * answer to "does this workspace have Automation / an Analyst?" is still unknown, at ANY speed. |
| * |
| * ⛔ TWO SYMPTOMS, ONE CAUSE, and the second one is what the owner's words actually describe. |
| * W31-T11 gave AUTOMATION the four-state slot below, so that row stopped vanishing. It left |
| * the AI-assistant row at `{analyst ? … : null}` with NO placeholder — and that row is ABOVE |
| * Query / Inbox / Automation / Database / **Connectors**. So when `/nav` landed it INJECTED a |
| * ~34 px row and pushed all five down a beat after first paint. Connectors (`:1765`) has no |
| * gate of any kind and cannot arrive late: it MOVES. "Automation and Connection module still |
| * loads separately" is one late row and one shoved row, from the same missing placeholder. |
| * |
| * ⚠ ONE FUNCTION, TWO CALLERS, ON PURPOSE. The four states were a hand-written ternary chain |
| * on one row; a second copy is how the two rows start disagreeing about what `degraded` means |
| * [[one-question-two-normalizers]]. `present` is the ONLY per-row input. |
| * |
| * ⭐⭐ W33-T75 SUPERSEDES BOTH, AND DELETES THE `pending` STATE ENTIRELY. See `RailSkeleton`: |
| * while `/nav` is in flight this rail draws NO rows, so there is no in-flight row state left |
| * to name. What remains is what the ANSWER says — the row, an `unavailable` arm when the |
| * answer was bad, and an inert `silent` box when the answer was "this workspace does not have |
| * it" (which keeps the geometry identical either way, T22's own finding). |
| * |
| * ⛔ `railLoading` IS THE SAME PREDICATE, WRITTEN ONCE. It is what the rail renders the |
| * skeleton on AND what makes the slot function unreachable, so the two cannot come to disagree |
| * about when the nav is settled [[one-question-two-normalizers]]. |
| */ |
| type RailSlot = "row" | "unavailable" | "silent"; |
| const railLoading = nav.phase === "loading" || nav.phase === "idle"; |
| const railSlot = (present: boolean): RailSlot => |
| present |
| ? "row" |
| : nav.phase === "error" |
| ? "unavailable" |
| : nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0 |
| ? "unavailable" |
| : "silent"; |
| const automationSlot: RailSlot = railSlot(!!automation); |
| const analystSlot: RailSlot = railSlot(!!analyst); |
| const dbEntries = databaseEntries(entries); |
| // WAVE 23 C10 — the flyout's search. A plain label substring, case-folded: this list is at |
| // most a few dozen rows, so anything cleverer (fuzzy, ranked) would be a scoring function |
| // nobody can predict over a set small enough to read. |
| // |
| // ⚠ SEARCHING RENDERS FLAT. `foldNav` is called with EMPTY prefs while a query is live, |
| // because a folder head whose members were filtered out would report a count it is not |
| // showing — the same "count derived from one list, rows from another" defect the R10 |
| // relocation note two blocks up already documents. |
| const dbQ = dbQuery.trim().toLowerCase(); |
| const shownEntries = dbQ |
| ? dbEntries.filter((e) => e.label.toLowerCase().includes(dbQ)) |
| : dbEntries; |
| // WAVE 23 C10 — what Home draws: the server's recents plus this session's own stamps. |
| const recents = mergeRecents(nav.phase === "ready" ? nav.recents : NO_RECENTS, opened); |
|
|
| return ( |
| <div className="shell-root"> |
| {/* ⭐⭐ WAVE 35 · T01 + T02 (owner item 1, ruling R1, contract C1) — THE ONE PERSISTENT TOP |
| STRIP. Owner: the "III" icon *"needs to be positioned on the edge of the left side of the |
| navigation instead of the edge of the right side ... the Loopable logo will be to the |
| right of the 'III' icon, aligned left as well."* |
| R1 chose a strip on EVERY module over one scoped to four, precisely so this cluster never |
| moves: it is a child of `.shell-root` and a SIBLING of the rail-and-content row, so it |
| spans the whole window and Database and Connectors keep their own header BELOW it. |
| ⛔ THE ORDER IS THE CONTRACT, NOT A PREFERENCE. C1: toggle FIRST, brand SECOND, both hard |
| left. `web_ui`'s W35-T01 leg asserts exactly that with an NC that swaps them. |
| ⛔⛔ AND THIS IS WHERE THE WAY BACK LIVES NOW. Wave-20 R9 made the LOGO the way out of a |
| collapsed rail, because `index.css` hid `.shell-rail-toggle` while collapsed — so moving |
| the logo out of the rail without moving the toggle too would have left a collapsed rail |
| with NO way to reopen it. Resolved by construction rather than by a new rule: the toggle |
| is no longer inside `.shell-side`, so `.shell-side.is-collapsed .shell-rail-toggle` can |
| no longer match it, and that dead rule is deleted rather than left to look load-bearing. |
| The toggle is therefore present and labelled in BOTH states, and the collapsed rail also |
| keeps its background-click (`expandOnBlank`). */} |
| <header className="shell-topbar"> |
| <button |
| type="button" |
| className="shell-topbar-toggle" |
| aria-label={navCollapsed ? "Expand navigation" : "Minimize navigation"} |
| aria-expanded={!navCollapsed} |
| title={navCollapsed ? "Expand navigation" : "Minimize navigation"} |
| onClick={navCollapsed ? expandRail : collapseRail} |
| > |
| <RailToggleIcon /> |
| </button> |
| <div className="shell-topbar-brand"> |
| <Brand size={26} className="shell-brand" /> |
| </div> |
| </header> |
| <div className="shell-frame"> |
| <aside |
| className={"shell-side" + (navCollapsed ? " is-collapsed" : "")} |
| // Item 7 (R9) — background click expands. Bound only while collapsed, so the |
| // open rail is exactly what it was; the accessible route stays the labelled |
| // brand button below ("Expand navigation"), which is what a keyboard reaches. |
| onClick={navCollapsed ? expandOnBlank : undefined} |
| > |
| {/* ⭐ WAVE 35 · T02 — THE HEAD IS AN ALIGNMENT BAND NOW AND NOTHING ELSE. The brand and |
| the toggle both moved into `.shell-topbar` above (C1), and contract C1 keeps this |
| element on its `--lp-rail-head-h` token deliberately: the content pane's own first |
| band is one `--lp-rail-head-h` tall, so deleting this would start the rail's first nav |
| row a header higher than the grid beside it and every database page would read as |
| misaligned. It is a spacer with the shared hairline, not a leftover. |
| ⚠ W36-T51 — THAT BAND USED TO BE NAMED AS `DbHead` OR `.cg-views-top`, AND `DbHead` IS |
| NOW DELETED FROM THIS SURFACE (owner item 7). The alignment survives because |
| `.cg-views-top` is still there and still one band tall; it is the VIEWS rail's head |
| that this spacer now lines up with. ⛔ So the thing that would orphan this element is |
| the views rail losing its head, NOT the database losing its title — a different edit |
| from the one a reader of the old sentence would have gone looking for. |
| ⚠ It is `aria-hidden` because it now has no content and no purpose a screen reader can |
| use; announcing an empty banner is noise. */} |
| <div className="shell-side-head" aria-hidden="true" /> |
|
|
| <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "")}> |
| {/* ⭐⭐ W33-T75 — THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below |
| (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows |
| and hiding them — rather than not rendering them — is deliberate: the flyout portal, |
| the tooltips and the account menu all hang off this subtree, and unmounting them for |
| the length of a fetch would tear down state that has nothing to do with the nav. */} |
| {railLoading ? <RailSkeleton /> : null} |
| {/* ⭐ WAVE 23 item 9 (R7, contract C10) — HOME, the new landing, at the top of the rail. |
| An `<a>` to a CHROME route: it needs no grant because it renders nothing the server |
| did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and |
| the :1039 law below is unchanged — an undeclared SURFACE is still denied). */} |
| <a |
| className={"shell-nav-item shell-nav-home" + (route === HOME_ROUTE ? " is-active" : "")} |
| href={`#/${HOME_ROUTE}`} |
| onMouseEnter={navCollapsed ? tipEnter("Home") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <HomeIcon /> |
| <span className="shell-nav-label">Home</span> |
| </a> |
|
|
| {/* ⭐⭐ WAVE 35 · T09 (owner item 8, R4) — STARRED, directly under Home. Owner: *"Let's add |
| a button under 'Home' called 'Starred' … only for Database/Agents/Queries/Marked |
| important that the user has marked."* |
| ⚠ IT TAKES THE SLOT INBOX HELD. Wave-34 R23 put Inbox *"right below Home"*; owner item |
| 8 is later and explicit about this position, so Inbox moves down one. Both rulings are |
| obeyed in their own order rather than one being quietly dropped. |
| ⭐ THE SHARED `StarIcon`, not a fourth hand-drawn star (B-3). It is already the app's |
| star — the view menu's and the grid's — and "one mark, one meaning" is DESIGN.md §4. |
| ⚠ `nav.ts::CHROME_ROUTES` is what lets this render with no grant: the page lists only |
| objects the caller was already given, so it shows nothing the server did not send. */} |
| <a |
| className={ |
| "shell-nav-item shell-nav-starred" + (route === STARRED_ROUTE ? " is-active" : "") |
| } |
| href={`#/${STARRED_ROUTE}`} |
| onMouseEnter={navCollapsed ? tipEnter("Starred") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <StarIcon size={16} className="shell-nav-icon" /> |
| <span className="shell-nav-label">Starred</span> |
| </a> |
|
|
| {/* ⭐⭐ WAVE 34 R15 + R23 — INBOX MOVED UP, directly under Home, and took a MAIL icon. |
| Owner, 2026-08-16: *"Home/Inbox (change the inbox logo to a mail icon) then a subtle |
| line separation. Then Assistant / Agents"* and, correcting the planner minutes later, |
| *"Alerts is called Inbox and it's right below Home"*. |
| ⚠ THAT SECOND SENTENCE KILLED A PLANNED SEVENTH ROW. The wave's first draft read the |
| rail as having BOTH an Alerts row and an Inbox row and scheduled work to keep them |
| ordered. There is only one row, and it has been wearing a bell while the page it opens |
| is an inbox — which is why the class is still `shell-nav-alerts` while the label, |
| the route and now the glyph all say Inbox. The class stays for one reason only: it is |
| a stable hook that `verify_wiring` and the collapsed-rail rules already key on, and |
| renaming it buys nothing a reader of this comment does not already have. */} |
| <a |
| className={"shell-nav-item shell-nav-alerts" + (route === INBOX_ROUTE ? " is-active" : "")} |
| href={`#/${INBOX_ROUTE}`} |
| onMouseEnter={navCollapsed ? tipEnter("Inbox") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <MailIcon /> |
| <span className="shell-nav-label">Inbox</span> |
| {badgeText(inbox.unread) ? ( |
| <span |
| className="shell-nav-badge" |
| // Not aria-hidden: the count IS the information, and a badge a screen |
| // reader cannot see makes the row read as an empty inbox. |
| aria-label={`${inbox.unread} unread`} |
| > |
| {badgeText(inbox.unread)} |
| </span> |
| ) : null} |
| </a> |
|
|
| {/* R15's "subtle line separation". A presentational hairline, so `role="none"` rather |
| than a `<hr>`: a screen reader gains nothing from being told there is a line, and the |
| rail's grouping is already carried by the labels themselves. |
| ⚠ It is a DIRECT CHILD of `.shell-nav` on purpose — that is what makes |
| `.shell-nav.is-rail-loading > *:not(.shell-rail-skeleton){display:none}` hide it along |
| with every row, so the skeleton never paints with two stray lines floating in it. */} |
| <div className="shell-nav-sep" role="none" /> |
|
|
| {/* The Analyst slot, above the database list — the host's own IA |
| (app.py:8166 pins "AI assistant" over the nav tree, same label, |
| same sparkle). Wave 18 (owner item 3): the hand-off into Streamlit |
| is PAUSED — the click opens an in-app "under construction" note |
| instead. The nav item, the registry row and the Streamlit Analyst |
| page all stay (kept-not-ported, never delete); only the door is |
| boarded until a later wave expands it. */} |
| {analyst ? ( |
| /* ⭐⭐ 2026-08-14 (owner item 2) — THE DOOR IS OPEN. Owner: *"you can unblock user |
| from accessing the AI assistant, in staging only so that we can see if the prompt |
| to query data works."* It used to open `setAssistOpen("assistant")` — an |
| under-construction note for the Streamlit Analyst that was DELETED at EXIT-6, so |
| the row led to an apology for a surface that is never coming back. |
| ⛔ STILL A `<button>`, NOT AN `<a>`, and that is deliberate rather than lazy. This |
| row's box is W33-T22 — the owner's own layout-jump report — and its three arms |
| (`analyst` / `pending` / `unavailable`) have to stay the same 34 px. `<a>` and |
| `<button>` do measure identically here (wave 19 R11's reset, measured on staging |
| v12), so this is belt not braces: changing the element is a risk with no return |
| when a hash write does the same job. */ |
| <button |
| type="button" |
| className={ |
| "shell-nav-item shell-nav-assist" + |
| (route === ASSISTANT_ROUTE ? " is-active" : "") |
| } |
| onClick={() => { |
| window.location.hash = `#/${ASSISTANT_ROUTE}`; |
| }} |
| onMouseEnter={navCollapsed ? tipEnter("Assistant") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <SparkIcon /> |
| <span className="shell-nav-label">Assistant</span> |
| </button> |
| ) : analystSlot === "unavailable" ? ( |
| /* ⛔ The payload never came, or came back `degraded`. Silence here is a claim that this |
| workspace has no assistant, and we do not know that — same argument as W31-T11's |
| unavailable arm one row below, same `title`, same quiet dot. */ |
| <div |
| className="shell-nav-item shell-nav-assist is-unavailable" |
| title={ |
| nav.phase === "error" && nav.timedOut |
| ? "The navigation request timed out. Reload to retry." |
| : "This list could not be loaded in full. Reload to retry." |
| } |
| > |
| <SparkIcon /> |
| <span className="shell-nav-label shell-nav-label--muted">Assistant</span> |
| <span className="shell-nav-note-dot" aria-hidden="true" /> |
| </div> |
| ) : analystSlot === "silent" ? ( |
| <div className="shell-nav-item shell-nav-assist is-rail-skeleton" aria-hidden="true" /> |
| ) : null} |
|
|
| {/* ⛔ THE "AI agent" ROW WAS HERE AND IS DELETED (owner, 2026-08-14): *"AI assistant |
| module IS AI agent module. So no need to separate it like that."* Wave 33's own note |
| on this row had already argued the collapse and deferred it as ASK C-9 because |
| W33-T22's done-when named the seven rail rows; the owner has now ruled, so the set |
| goes back to those seven. `verify_wiring.py` asserts the absence rather than merely |
| dropping the check that asserted the presence — a deleted check is |
| [[gate-can-report-green-on-nothing]]. */} |
|
|
| {/* ⛔⛔ THE QUERY ROW WAS HERE AND IS DELETED (WAVE 34 R14). Owner, 2026-08-16: *"I want |
| to combine the AI assistant module AND the Query module… just above '+ New chat' I |
| want you to have an easily accessible toggle between Chat/Query."* |
| Query did not lose its surface — it lost its RAIL ROW, because a toggle inside the |
| assistant is now the way in. `#/query` still resolves and redirects (`W34-T15`), so |
| every existing bookmark and every `openBuiltView` hand-off keeps working. |
| ⚠ THIS COMMENT ASSERTED SOMETHING FALSE FOR ABOUT A MINUTE, and the correction is |
| kept because the habit is the useful part. It read *"`SearchIcon` survives as a |
| symbol: the flyout's own filter box draws it"* — it does not, and `tsc` (TS6133) said |
| so on the very next run. The function was deleted in the same edit; its tombstone is |
| where it used to live, one screen up. **A claim about who calls a symbol is |
| answerable in one command, so ask the compiler rather than reasoning about it.** |
| Found again at the close-out dead-code sweep, which greps every deleted symbol and |
| reads each survivor: this one was a stale CLAIM rather than a live caller. */} |
|
|
| {/* WAVE 19 R10 / C3 — Automation, now AGENTS. An `<a>` to the route it already had: the |
| surface, its rail and its editor are another session's tree this wave and |
| are not touched by any of this. The LABEL comes off the payload, never a |
| literal here — the registry owns what this surface is called, and a client |
| that hard-codes the word is the drift that outlives the row. */} |
| {automation ? ( |
| <a |
| className={ |
| "shell-nav-item shell-nav-auto" + |
| (active && active.key === automation.key ? " is-active" : "") |
| } |
| href={automation.href} |
| onMouseEnter={navCollapsed ? tipEnter(automation.label) : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| {/* ⭐⭐ WAVE 35 · T04 (owner item 5) — THE ROBOT, UNCONDITIONALLY. Owner: *"Change |
| the icon logo for Agents, let's just have it look like a robot."* |
| ⛔ THIS WAS `automation.icon ? FolderMark : RobotIcon`, AND THAT TERNARY IS THE |
| WHOLE DEFECT — not a missing glyph. `RobotIcon` has existed since wave 34 R15 and |
| simply LOST: on any tenant that had ever chosen a mark for `automation`, the |
| chosen coloured chip won and the robot never painted. So the bug reads as "the |
| icon is wrong" while the code looks like it already does the right thing, which |
| is why owner items 2 and 5 turned out to share one cause. |
| ⚠ The wave-23 R8 note this replaces argued Automation is among the databases that |
| may carry a chosen icon. R8 is not revoked for DATABASES; what changed is that |
| Agents is a MODULE — a fixed rail row like Home or Connectors — and a module's |
| glyph is the product's, not the tenant's. `FolderMark` is still how every real |
| database draws its chosen mark. */} |
| <RobotIcon /> |
| <span className="shell-nav-label">{automation.label}</span> |
| </a> |
| ) : automationSlot === "unavailable" ? ( |
| /* ⛔ W31-T11 — NOT LOADING, AND NOT ABSENT-ON-PURPOSE: the server said this payload |
| is incomplete (`degraded`), or it never answered. The row states that rather than |
| leaving a gap the reader has to interpret — which is the whole of owner item 6b. */ |
| <div |
| className="shell-nav-item shell-nav-auto is-unavailable" |
| title={ |
| nav.phase === "error" && nav.timedOut |
| ? "The navigation request timed out. Reload to retry." |
| : "This list could not be loaded in full. Reload to retry." |
| } |
| > |
| <RobotIcon /> |
| <span className="shell-nav-label shell-nav-label--muted">Agents</span> |
| <span className="shell-nav-note-dot" aria-hidden="true" /> |
| </div> |
| ) : automationSlot === "silent" ? ( |
| <div className="shell-nav-item shell-nav-auto is-rail-skeleton" aria-hidden="true" /> |
| ) : null} |
|
|
| {/* R15's second hairline: the AI pair above, the data pair below. Same `role="none"` |
| and the same direct-child placement as the first one, for the same reason. */} |
| <div className="shell-nav-sep" role="none" /> |
|
|
| {/* ⭐ WAVE 23 item 9 (R7) — THE DATABASE BUTTON, and the end of the always-on band. |
| R7: "databases LEAVE the always-on rail; a 'Database' nav button opens the flyout". |
| The rail is now a fixed set of destinations whose height does not depend on how many |
| tables the tenant made — which is the actual complaint behind the ruling. |
| ⛔ A BUTTON, NOT A ROUTE, and for the same reason Alerts is one (:1039): there is no |
| `#/databases` surface and inventing one would be the hard-coded page this frame |
| refuses to have. It opens a panel over the list the server already sent. */} |
| <button |
| type="button" |
| ref={dbButton} |
| // ⛔⛔ WAVE 35 QA — `dbAt` ALONE IS THE FLYOUT ANCHOR, NOT THE CURRENT SURFACE, AND |
| // THAT MADE W35-T03 FALSE ON THE ONE ROW A PERSON SPENDS MOST OF THEIR DAY IN. |
| // Measured on the deployed build: standing on "Odoo customers", `.shell-nav-db` read |
| // exactly `shell-nav-item shell-nav-db` — every rail row painted identically plain, |
| // nothing marking Database as current. It lit up only while the flyout was manually |
| // REOPENED, which is a click the ticket's "at a glance" does not allow for. Six of the |
| // seven rows in that clause were right; this one was gated on the wrong fact. |
| // ⭐ THE FLYOUT'S OWN ROWS ALREADY HAD THE RIGHT ANSWER (`active.key === item.key`, |
| // below) — the button was the only thing in the rail reading a different question. |
| // So this asks the SAME question the rows ask: is the resolved route one of the |
| // databases this flyout lists? [[one-evaluator-per-question]] |
| className={"shell-nav-item shell-nav-db" |
| + (dbAt || (!!active && dbEntries.some((e) => e.key === active.key)) |
| ? " is-active" : "")} |
| aria-haspopup="dialog" |
| aria-expanded={!!dbAt} |
| onMouseEnter={navCollapsed ? tipEnter("Database") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| onClick={(e) => { |
| if (dbAt) { |
| closeDbFly(); |
| return; |
| } |
| const at = flyoutFrom(e.currentTarget); |
| if (at) setDbAt(at); |
| }} |
| > |
| <DbIcon /> |
| <span className="shell-nav-label">Database</span> |
| <CaretIcon /> |
| </button> |
|
|
| {/* ⭐ WAVE 23 item 10 (R8, contract C11, wiring W23-W3) — CONNECTORS. |
| ⭐ WAVE 24 item 1 — it moved BELOW Database. R8 put it above; the owner's wave-24 |
| order puts the two destinations that open panels-over-server-data (Database, then |
| Connectors) at the foot of the rail. A chrome route like Home: the DIRECTORY it |
| renders is composed by the server and session-gated, so this row can no more invent |
| a connector than Home can invent a database. */} |
| <a |
| className={ |
| "shell-nav-item shell-nav-connectors" + |
| (route === CONNECTORS_ROUTE ? " is-active" : "") |
| } |
| href={`#/${CONNECTORS_ROUTE}`} |
| onMouseEnter={navCollapsed ? tipEnter("Connectors") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <PlugIcon /> |
| <span className="shell-nav-label">Connectors</span> |
| </a> |
|
|
| {/* ⛔ "+ Create new…" IS NO LONGER A RAIL ROW (item 15a, ruling R9). It moved into the |
| Database flyout's footer, where the things it creates actually live — and where the |
| three `.shell-dbfly-make` buttons used to be. See the footer below, and |
| `CreateNewRow`'s own note for why this is also item 1's fix rather than a separate |
| cosmetic change: it was the one row in this band whose type differed. */} |
|
|
| {/* ── THE DATABASE FLYOUT (C10) ──────────────────────────────────────────────────── |
| ⛔ PORTALLED TO `<body>`, and it has to be. Left inside the `<aside>` it would be a |
| descendant of `.shell-side.is-collapsed`, whose rules hide `.shell-nav-label`, |
| `.shell-nav-badge` and `.shell-nav-open` — so with the rail folded the panel would |
| render a column of unlabelled icons, which is the one state this control exists to |
| rescue the user from. `createPortal` is already this tree's answer for exactly this |
| (OverlaySurface, CatalogView); the panel is not part of the rail, it only points at |
| it. */} |
| {dbAt |
| ? createPortal( |
| <div |
| className="shell-dbfly" |
| ref={dbPanel} |
| role="dialog" |
| aria-label="Databases" |
| style={{ left: dbAt.x, top: dbAt.y }} |
| > |
| <div className="shell-dbfly-head"> |
| <input |
| autoFocus |
| className="shell-dbfly-search" |
| placeholder="Search databases" |
| value={dbQuery} |
| maxLength={60} |
| onChange={(e) => setDbQuery(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Escape") closeDbFly(); |
| }} |
| /> |
| </div> |
| <div |
| className={ |
| "shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "") |
| } |
| onDragOver={(e) => { |
| if (!e.dataTransfer.types.includes(NAV_DRAG_TYPE)) return; |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = "move"; |
| setDropTarget("__root__"); |
| }} |
| onDragLeave={(e) => { |
| if (e.currentTarget.contains(e.relatedTarget as Node)) return; |
| setDropTarget(null); |
| }} |
| onDrop={(e) => { |
| const key = e.dataTransfer.getData(NAV_DRAG_TYPE); |
| setDropTarget(null); |
| setDragKey(null); |
| if (!key) return; |
| e.preventDefault(); |
| movePage(key, null); |
| }} |
| > |
| {/* C-SCHEMA: the list folds under the user's folders. |
| ⭐ WAVE 23 C10 — the `navCollapsed ? EMPTY_NAV_PREFS :` gate is GONE with the band |
| it belonged to. It existed because the 44px strip had no room for folder chrome; |
| this panel is 268px wide whatever the rail is doing, so the folders always render |
| and the reason for the gate no longer exists. A SEARCH still flattens (see |
| `shownEntries`) — that is a different fact about a different state. */} |
| {foldNav(shownEntries, dbQ ? EMPTY_NAV_PREFS : navPrefs, closedFolders).map( |
| (row) => { |
| if (row.kind === "folder") { |
| return ( |
| <FolderHead |
| key={`folder:${row.folder.id}`} |
| folder={row.folder} |
| count={row.count} |
| // ⭐ W34-T11 / C1 — `foldNav` sets this only for a CLOSED folder, so the |
| // "an expanded one does not double-count" clause is decided where the |
| // members are known rather than re-derived here. |
| open={row.open} |
| // WAVE 23 C10 — always false in the flyout: the panel has full width in |
| // either rail state, so folder chrome is never the thing being squeezed. |
| collapsed={false} |
| onToggle={() => toggleFolder(row.folder.id)} |
| onRename={(name) => renameFolder(row.folder.id, name)} |
| onDelete={() => deleteFolder(row.folder.id)} |
| isDrop={dropTarget === row.folder.id} |
| dropProps={{ |
| onDragOver: (e) => { |
| if (!e.dataTransfer.types.includes(NAV_DRAG_TYPE)) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.dataTransfer.dropEffect = "move"; |
| setDropTarget(row.folder.id); |
| }, |
| onDrop: (e) => { |
| const key = e.dataTransfer.getData(NAV_DRAG_TYPE); |
| setDropTarget(null); |
| setDragKey(null); |
| if (!key) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| movePage(key, row.folder.id); |
| }, |
| }} |
| /> |
| ); |
| } |
| const item = row.entry; |
| // Wave 17 item 12 — ONE answer, read by the row AND the link. |
| // The row paints the tint (so it reaches under the ⋯, matching |
| // the Views rail); the link keeps the class because the weight |
| // and the full-strength icon hang off `.shell-nav-item.is-active`. |
| const isActive = !!active && active.key === item.key; |
| const cls = |
| "shell-nav-item" + |
| (item.depth > 0 ? " is-child" : "") + |
| (isActive ? " is-active" : ""); |
| const inner = ( |
| <> |
| {/* WAVE 19 R8 — the tenant's chosen mark, or the cylinder. |
| ⚠ `FolderMark` paints its OWN pastel fill and `-deep` |
| stroke, so it must not wear `.shell-nav-icon` (which sets |
| `stroke: currentColor` and dims to 0.55 — that rule would |
| repaint a deliberately-coloured glyph in the row's ink and |
| then half-erase it). The wrapper span carries only the |
| box, which is why the class is the shell's own and not the |
| grid's `.cg-folder-mark`: that one belongs to another |
| session's CSS region this wave. */} |
| {item.icon ? ( |
| <span className="shell-nav-mark" aria-hidden="true"> |
| <FolderMark icon={item.icon} size={16} /> |
| </span> |
| ) : ( |
| <DbIcon /> |
| )} |
| <span className="shell-nav-label">{item.label}</span> |
| {/* ⭐ WAVE 27 item 3 (contract C9) — A LOCKED DATABASE SAYS SO HERE. |
| The nav flyout is the only place every database is listed, so it is |
| where the fact belongs — you learn it before you open the thing and |
| go looking for a "+" that will not be there. |
|
|
| ⚠ THE TITLE NAMES **WHICH** LOCK, and that is a standing rule rather |
| than politeness: ONE `LockMark` glyph carries all three of the owner's |
| locks (locked database / pre-set field / locked view — DESIGN.md §4), |
| so a bare padlock says only "something here is restricted". This one |
| means records, and it says the half that STAYS OPEN in the same breath, |
| because "locked" reads as read-only and fields are not. */} |
| {item.locked ? ( |
| <span |
| className="shell-nav-lock" |
| title="Locked database. Records are added by an automation. You can still add and edit fields." |
| > |
| <LockMark size={11} /> |
| </span> |
| ) : null} |
| {/* A hand-off opens the current application in a new tab; the |
| arrow declares that before the click finds out. */} |
| {item.kind === "handoff" ? <ExtIcon /> : null} |
| {/* ⛔⛔ WAVE 35 · T07 (owner item 9, R4) — THE MARK-IMPORTANT NUMBER IS GONE |
| FROM THE DATABASE FLYOUT, and it is wave 34's R1 being retired by a later |
| instruction rather than a regression. R1 moved it HERE ("the mark important |
| number should be moved into the database navigation"); item 9 says remove it |
| *"anywhere else but the View itself"* and put the marked VIEWS on Home and |
| under Starred instead (W35-T16/T17). The per-view badge inside a database is |
| untouched — that is "the View itself". |
| ⚠ Deleting the element is only half: `/nav` stopped computing the number at |
| all (W35-T43, session E), which is what closes D-288's cold-start cost and |
| D-289. A client that merely hid it would have kept paying for it. */} |
| {/* Wave 14 item 6: the "Odoo" provenance badge is GONE — the schema |
| drawer still names the source for whoever asks. */} |
| </> |
| ); |
| if (item.kind === "group") { |
| // A registry family head is not a destination — the app has no |
| // page for it either. A label, never a link. |
| return ( |
| <div key={item.key} className="shell-nav-group"> |
| {inner} |
| </div> |
| ); |
| } |
| // ⭐ WAVE 23 C10 — the collapsed-rail hover tip is GONE from these rows, with |
| // the band it belonged to. It named a database whose LABEL was hidden at 44px; |
| // inside the flyout the label is always on screen, so the tip would have been a |
| // second copy of the word beside itself. `tipEnter`/`tipLeave` still serve the |
| // rail's own rows above. |
| // |
| // Clicking a database CLOSES the panel: you asked for it, you got it. The |
| // handler is on the LINK and not on the list, deliberately — a click on the |
| // row's ⋯ must leave the panel open, because that menu is anchored inside it. |
| const link = |
| item.kind === "native" ? ( |
| <a className={cls} href={item.href} onClick={closeDbFly}> |
| {inner} |
| </a> |
| ) : ( |
| <a |
| className={cls} |
| href={item.href} |
| target="_blank" |
| rel="noreferrer" |
| onClick={closeDbFly} |
| > |
| {inner} |
| </a> |
| ); |
| // The three-dots rides TOP-LEVEL rows in the open rail only — |
| // children move with their family, and the folded rail has no |
| // horizontal room for a second control. |
| return ( |
| <div |
| key={item.key} |
| className={ |
| "shell-nav-row" + |
| (isActive ? " is-active" : "") + |
| (row.folderId ? " is-foldered" : "") + |
| (dragKey === item.key ? " is-dragging" : "") |
| } |
| // WAVE 23 C10 — `!navCollapsed &&` dropped from both gates below: the panel |
| // is the same width in either rail state, so the two things that gate |
| // referred to (no horizontal room for a ⋯, no room to drag) are no longer |
| // true. The folder-reorder drag and the row menu therefore keep working with |
| // the rail folded, which is the state a user who opened this panel is most |
| // likely to be in. |
| draggable={item.depth === 0} |
| onDragStart={(e) => { |
| e.dataTransfer.setData(NAV_DRAG_TYPE, item.key); |
| e.dataTransfer.effectAllowed = "move"; |
| setDragKey(item.key); |
| }} |
| onDragEnd={() => { |
| setDragKey(null); |
| setDropTarget(null); |
| }} |
| > |
| {link} |
| {item.depth === 0 && ( |
| <RowMenu |
| entryLabel={item.label} |
| canSchema |
| onSchema={() => setSchemaFor(item.key)} |
| // ── WAVE 19 R8 / C1, walled by R14 ────────────────── |
| // RENAME is `ut_*` only: a built-in label is a compiled |
| // registry literal, and renaming one would leave the nav |
| // and every other reader of `core/registry.py` calling |
| // the same module two different things. ICONS ride every |
| // database — that half of R8 is explicitly "ALL". |
| // |
| // ⛔ WHO MAY IS THE SERVER'S ANSWER, NOT A ROLE CHECK |
| // HERE. R14 put the `ut_` half on `user_tables.may_open` |
| // — the table's CREATOR or a tenant admin — and this |
| // client cannot see who created a user table. So the nav |
| // payload carries `manage` per row and the rail simply |
| // obeys it. Absent reads as NO (fail-closed), and |
| // `POST /nav/meta` re-checks regardless: this is the |
| // courtesy half of "the client hides, the server |
| // forbids", the same one `reachableSection` pays in |
| // Settings. An earlier build gated on `isAdmin` here and |
| // was wrong in the direction that matters — it hid a |
| // control from the person who owned the thing. |
| canRename={!!item.manage && item.key.startsWith("ut_")} |
| onRename={(name) => void commitNavMeta(item.key, { name })} |
| canIcon={!!item.manage} |
| {...(item.icon ? { icon: item.icon } : {})} |
| onIcon={(icon: FolderIcon) => |
| void commitNavMeta(item.key, { icon }) |
| } |
| onIconClear={() => void commitNavMeta(item.key, { icon: null })} |
| // WAVE 20 item 18 (R10) — share THIS database. Offered on the |
| // user's own tables only: `customer_data` and `product_data` are |
| // registry surfaces whose reach is the permission wall's answer, |
| // not one person's to grant ([[aios-permission-wall]]), and a |
| // dialog that recorded a grant the module gate would then ignore |
| // is the "shared, silently inert" failure in reverse. |
| {...(item.key.startsWith("ut_") |
| ? { |
| onShare: () => |
| setShareFor({ |
| kind: "database", |
| id: item.key, |
| label: item.label, |
| }), |
| } |
| : {})} |
| // ⭐ WAVE 21 item 6 (R3, C3, wiring W-5) — THE MOUNT. |
| // |
| // `canDelete` is the SERVER's answer and a REQUIRED prop, so |
| // this expression not being here is a compile error rather |
| // than a feature that quietly does not exist (wave 20's |
| // lesson, written into the type — see RowMenu's own note). |
| // |
| // ⛔ NOT `manage`, and the difference is the whole ruling. |
| // `manage` rides `may_open`, which R14/D-32 widened to |
| // everyone the table is SHARED with; R3 puts delete on the |
| // creator or an admin alone, and refuses it outright for |
| // connector-backed databases. Two questions, two flags. |
| canDelete={!!item.canDelete} |
| onLoadFootprint={() => fetchTableFootprint(item.key)} |
| onDelete={() => removeDatabase(item.key)} |
| /> |
| )} |
| </div> |
| ); |
| } |
| )} |
| {/* The panel's own empty state: ONE line, and it is different from "this |
| workspace has none" — a search that matched nothing is not a workspace |
| with nothing in it, and saying the second when the first is true is how a |
| reader concludes their data is gone. */} |
| {shownEntries.length === 0 ? ( |
| <p className="shell-dbfly-empty"> |
| {dbQuery ? "No database matches that." : "No databases yet."} |
| </p> |
| ) : null} |
| </div> |
| {/* ── the create footer ───────────────────────────────────────────────── |
| ⭐ WAVE 24 item 15a (ruling R9, wiring W24-W4) — ONE CONTROL, THE CREATE |
| DOORS. C10's three `.shell-dbfly-make` buttons are deleted and |
| "+ Create new…" moves here from the rail, carrying New database · |
| From a template · New folder. |
|
|
| ⭐ WAVE 25 item 5a (R8) — IT WAS FOUR DOORS AND IS NOW THREE. "Automated |
| database" is deleted here, on Home, and in the New-database dialog. Making |
| an automation is not a way of making a database, and a menu that offered it |
| beside "New database" said it was. |
|
|
| ⚠ NOTHING THE THREE BUTTONS OPENED IS LOST, and that was the constraint: |
| blank and template still open the same New-database dialog with the choice |
| pre-selected — and FOLDERS, which the rail row owned and the three buttons |
| never offered, keep their only creation door. |
|
|
| ⚠ EACH DOOR CLOSES THE PANEL FIRST, exactly as the buttons did. `New |
| folder` is the one that must NOT: it opens an inline naming form inside |
| this footer, and `createFolder` re-opens the panel so the new folder is |
| visible (C10's own fix for an affordance whose effect was invisible). |
| `CreateNewRow` keeps that form internal, so closing here would unmount the |
| thing the user is typing into. */} |
| <div className="shell-dbfly-foot"> |
| <CreateNewRow |
| collapsed={navCollapsed} |
| onExpand={expandRail} |
| onCreateFolder={createFolder} |
| onNewDatabase={() => { |
| closeDbFly(); |
| openNewDb("blank"); |
| }} |
| onFromTemplate={() => { |
| closeDbFly(); |
| openNewDb("template"); |
| }} |
| canFolder={entries.length > 0} |
| /> |
| </div> |
| </div>, |
| document.body |
| ) |
| : null} |
| {/* W33-T75: loading is represented only by the two row-local slots above. A separate |
| nav-level spinner here sat beneath Connectors and looked like a late eighth row. */} |
| {/* Honest, and it names the fix. Inventing a nav here would show |
| surfaces the server never granted. */} |
| {nav.phase === "error" ? ( |
| <div className="shell-nav-note"> |
| {nav.timedOut |
| ? /* ⭐ W31-T11 — A DEADLINE IS A DIFFERENT FACT FROM A DEAD SERVER, and it earns |
| its own sentence: before this ticket `fetchNav` had no timeout at all, so |
| this state was UNREACHABLE and the rail waited for ever. */ |
| "Navigation took too long to load. Reload to retry." |
| : "Navigation unavailable. Reload to retry."} |
| </div> |
| ) : null} |
| {/* ⛔ W31-T11 — A 200 THAT COULD NOT READ THE DATABASES SAYS SO. This is the case that |
| had no surface at all: the server swallowed a store failure, answered 200 with every |
| `ut_*` row missing, and the rail drew a confident, complete-looking list of nothing. |
| It is NOT `phase: "error"` — the registry rows are real and usable — so it renders |
| beside them rather than replacing them. */} |
| {nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0 ? ( |
| <div className="shell-nav-note"> |
| Some databases could not be loaded. Reload to retry. |
| </div> |
| ) : null} |
| {/* ⚠ Wave 18: this line is for a MISCONFIGURED ACCOUNT — one whose grants give it |
| nothing — and it must not fire for a freshly provisioned TENANT, which has no |
| modules by design and is being welcomed on Home. Both at once said "something is |
| wrong here" and "welcome, start here" in one screen (caught in the close-out |
| visual pass, not by any gate). "+ Create new…" above is the honest affordance in |
| the tenant case. */} |
| {nav.phase === "ready" && entries.length === 0 && !tenantEmptyState ? ( |
| <div className="shell-nav-note">No surfaces are available to this account.</div> |
| ) : null} |
| </nav> |
|
|
| {/* ⭐⭐ WAVE 35 · T05 (owner item 6) — TEMPLATES, DIRECTLY ABOVE THE USER BUTTON. |
| ⛔ IT OPENS THE SAME DOOR HOME'S CARD OPENS — `setAssistOpen("templates")`, the exact |
| handler passed to Home as `onTemplates`. A second under-construction note would be a |
| second answer to one question, and the two would drift the day either is replaced by a |
| real picker. |
| ⛔ A SIBLING OF `.shell-side-bottom`, NOT A CHILD, and that is behavioural rather than |
| cosmetic: that band carries an `onClickCapture` which, while collapsed, swallows the |
| click and expands the rail instead (the account POPOVER cannot fit in a 56px strip). |
| Nesting this inside it would make Templates unreachable in one click whenever the rail |
| is folded — the account row's compromise applied to a row that has no popover and does |
| not need it. |
| ⚠ The tip is the trap the ticket names: collapsed, `.shell-nav-label` folds to zero |
| width, so without `tipEnter` this becomes an unlabelled glyph like every other rail row |
| would. */} |
| <button |
| type="button" |
| // ⛔⛔ IT FOLLOWS THE RAIL'S LOADING STATE, AND A SCREENSHOT IS THE ONLY THING THAT |
| // CATCHES THIS. `.shell-nav.is-rail-loading > *:not(.shell-rail-skeleton)` hides every |
| // real nav row while `/nav` is in flight (W33-T75), but this row is a SIBLING of |
| // `.shell-nav`, so that rule cannot reach it: the first build painted "Templates" in ink |
| // beneath six skeleton bars. That is two visibly different row states in one list, which |
| // is owner item 1 verbatim — the complaint that took four waves and four "fixes" to |
| // settle, and whose acceptance test is "THE RAIL PAINTS ONCE", never "the spinner is |
| // gone". [[a-rail-must-paint-once]] |
| className={"shell-nav-item shell-nav-templates" + (railLoading ? " is-rail-hidden" : "")} |
| onClick={() => setAssistOpen("templates")} |
| onMouseEnter={navCollapsed ? tipEnter("Templates") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <TemplatesIcon /> |
| <span className="shell-nav-label">Templates</span> |
| </button> |
|
|
| <div |
| className="shell-side-bottom" |
| // Collapsed, the 56px strip cannot hold the account POPOVER — so the row's one |
| // honest behaviour is "expand me first". Capture-phase, so the menu never opens |
| // half-clipped behind the grid. |
| onClickCapture={ |
| navCollapsed |
| ? (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| expandRail(); |
| } |
| : undefined |
| } |
| onMouseEnter={navCollapsed ? tipEnter(session.user.name) : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <AccountMenu |
| user={session.user} |
| utility={utility} |
| onSignOut={signOut} |
| onSettings={setSettings} |
| /> |
| </div> |
| </aside> |
|
|
| {/* Item 3 — the collapsed rail's hover tip: fixed beside the strip, outside the |
| scrolling rail that would clip it, and pointer-transparent by standing rule. */} |
| {navTip ? ( |
| <div |
| className="shell-nav-tip" |
| style={{ top: navTip.y }} |
| role="tooltip" |
| aria-hidden="true" |
| > |
| {navTip.label} |
| </div> |
| ) : null} |
|
|
| <main className="shell-main"> |
| {/* ⭐ WAVE 30 (R5) — THE CONTENT-AREA BOUNDARY, and the reason it is HERE and not around |
| the whole shell: the `<aside>` rail is its SIBLING, so a surface that throws leaves the |
| navigation mounted and the user can walk away from the broken page instead of reloading |
| a blank document. That is the difference the ticket's done-when is naming. |
| ⚠ `key={route}` is load-bearing. A boundary LATCHES — once it holds an error it renders |
| the panel until it is remounted — so without the key, navigating away from a failing |
| database would carry its failure card onto every page afterwards. */} |
| <ErrorBoundary key={route} surface={active?.label ?? "This page"}> |
| {/* ⚠ An honest failure, never a fallback to the bundled sample. Behind |
| a login, plausible-looking sample revenue is fabricated data on a |
| screen the user has every reason to trust. */} |
| {dataError && active?.kind === "native" ? ( |
| <div className="shell-placeholder"> |
| <h1>{active.label}</h1> |
| <p>{dataError}</p> |
| <button className="login-submit shell-retry" type="button" onClick={() => window.location.reload()}> |
| Retry |
| </button> |
| </div> |
| ) : active?.kind === "native" && ENVELOPE_KEYS.has(active.key) ? ( |
| // EXIT wave 2 — a Y1-envelope page. The KEY is the only thing that |
| // varies: wave 3's Collections and Procurement come through this same |
| // branch, which is the whole return on the envelope contract. |
| <PageSurface key={active.key} pageKey={active.key} label={active.label} /> |
| ) : active?.kind === "native" && active.key === "automation" ? ( |
| // Wave 18 (C-AUTONAV): the Automation surface — its OWN secondary rail + editor, |
| // props-free by contract (it fetches /api/v1/automations itself). SESSION D's tree. |
| // |
| // ⭐ WAVE 23 C13 (owner item 2), wiring W23-W1 — THE SAME FRAME AS EVERY DATABASE. |
| // |
| // This branch used to mount the surface BARE: no `shell-db-frame`, no `DbHead`. So |
| // the one page in the product that is not a database was also the one page whose |
| // name was drawn by its own component, at its own size and weight — three title |
| // treatments (the surface's 16px/700 editable input, its 20px/600 empty-state h1, |
| // and the frame's 16px/650 `shell-db-name`) against the grid's one. The fix is not |
| // to restyle the stand-in but to delete the reason it exists: one title system, |
| // owned by the shell, mounted here. |
| // |
| // ⚠ THE LABEL COMES OFF THE PAYLOAD, exactly as the rail's does (:1006). C13 words |
| // it "label 'Automation'" and that IS `active.label` — the registry's literal, or a |
| // tenant's `nav_meta` override of it. Hard-coding the word here would put the same |
| // surface's name in two places and let them drift on the day someone renames it. |
| // |
| // ⛔ THE HEIGHT CHAIN. `.auto-surface` is `height: 100%`, so dropping it straight |
| // into the frame's flex column would size it against the WHOLE frame and push its |
| // rail a header's height below the fold. `.shell-auto-host` is the same `flex: 1 1 |
| // auto; min-height: 0` link `.shell-grid-host` is for glide — and it is the shell's |
| // OWN class rather than a `.shell-db-frame > .auto-surface` rule, because |
| // `.auto-*` is SESSION B's CSS region this wave and a frame has no business |
| // reaching into its child's namespace to make itself fit. |
| /* ⭐⭐ WAVE 35 · T32 (owner item 2, ask D-1) — THE AGENTS HEAD IS GONE; THE FRAME STAYS. |
| Owner: *"Let's remove that header for Agent completely."* Agents opens straight onto |
| its surface, the way Assistant does. |
| ⛔ THE EDIT IS HERE, NOT IN `AutomationSurface.tsx`, and D found that: T32's `files:` |
| and `how:` both said the surface "mounts `shell-db-frame` + `DbHead`", but the surface |
| renders no head at all — this branch does. Written as the PRD says and nobody would |
| have owned the edit. Recorded as amendment A3. |
| ⛔ THE HEAD, NOT THE FRAME. `.shell-db-frame` is the rail-and-content GEOMETRY and |
| `.shell-auto-host` is the `flex: 1 1 auto; min-height: 0` link the surface's |
| `height: 100%` needs; dropping either collapses the box. Wave-23 C13's argument for |
| ONE title system is not revoked — it still holds for every real DATABASE, which is |
| what `DbHead` is for. Agents is a MODULE, and a module titles itself or not at all. */ |
| <div className="shell-db-frame"> |
| <div className="shell-auto-host"> |
| {/* USER-VISIBLE: ErrorBoundary renders "{surface} could not be shown" (T40, QA) */} |
| <Lazily surface="Agents"><AutomationSurface /></Lazily> |
| </div> |
| </div> |
| ) : active?.kind === "native" ? ( |
| // The native grid surfaces — ONE component, topic decided by the route. Wave 16: |
| // the `#/cohort` route left with the cohort registry row (cohorts are LOCKED VIEWS |
| // in the Customer rail, C-LOCK; `scope=cohort` stays a working server surface for |
| // stored artifacts), and `#/product_data` arrived (C-TOPIC) — the same tree over |
| // the SKU catalogue. The `key` forces a remount on a route change so one topic's |
| // view state never bleeds into the other's. Wave 18 (C3-UT): a `ut_` route is a |
| // USER TABLE through the same tree — scope IS the key — with the shell-owned |
| // Add-record bar above it (the doc's amendment: zero CustomerGrid edits). |
| // WAVE 20 item 4 (R8): ONE frame for every database. The old branch gave user |
| // tables a header-plus-grid frame and the built-ins a bare grid, which is why |
| // Customer and Product had nowhere to put a name. |
| /* ⭐⭐ WAVE 36 · T51 (owner item 7) — AND THE HEADER IS GONE FROM THIS BRANCH. Owner: |
| *"Remove the header for database completely. its a waste of white space. User should |
| be able to tell what database they are in just by seeing the first Unique ID field. |
| its ok."* That last sentence is the whole argument and it is why the deletion is safe |
| HERE and not everywhere: a database grid's first column IS its identity, on screen, |
| in every row. Wave-20 R8's "one title system" was right that the two surfaces needed |
| the SAME answer; the owner has now given a different one. |
| ⛔ THE FRAME STAYS, THE HEAD GOES — the same distinction W35-T32 drew for Agents six |
| comments up. `.shell-db-frame` is the rail-and-content GEOMETRY; dropping it collapses |
| the box. Only the 44px title band is reclaimed, and the grid takes it: `.shell-grid- |
| host` is `height: 100%` inside a column that now has one fewer fixed row. |
| ⚠ `.shell-side-head` (index.css) IS NOT ORPHANED BY THIS, checked rather than assumed: |
| its comment says it exists to align the rail's first nav row with a 44px band, and the |
| band it now aligns with is `.cg-views-top`, which is still there and still |
| `--lp-rail-head-h` tall. It would only become dead if the VIEWS rail lost its head. |
| ⛔ `DbHead` ITSELF SURVIVES UNTIL W36-T67 LANDS. `query/QueryPage.tsx:138` still mounts |
| it and that file is session D's fence — a component cannot be deleted while another |
| fence imports it, which is why T51 is blocked-by T67 rather than the reverse. */ |
| <div className="shell-db-frame"> |
| <div className="shell-grid-host"> |
| <OverlayProvider> |
| {/* ⭐ 2026-08-14 — `gridScopeFor` (shell/dbFrame.tsx), not the inline ternary that |
| stood here. Identical answers; the difference is that the Query module needs |
| the SAME map, and the two spellings (registry key vs grid scope) fail SILENTLY |
| when they diverge — `CustomerGrid`'s VIEW_OPEN listener drops an event whose |
| `topic` is the other one. [[one-question-two-normalizers]] */} |
| <CustomerGrid key={active.key} scope={gridScopeFor(active.key)} /> |
| </OverlayProvider> |
| </div> |
| </div> |
| ) : active ? ( |
| <StranglerPage entry={active} /> |
| ) : route === STARRED_ROUTE ? ( |
| // ⭐⭐ WAVE 35 · T09 (owner item 8, wiring W1) — the Starred surface, session B's tree. |
| // ⛔ `dbEntries`, NOT `entries`, and B's own note says so for the same reason Home's does: |
| // the Agents SURFACE is a granted nav entry with an href, so `entries` would draw it a |
| // tile under the heading "Databases", beside the section listing what it contains. That |
| // was a real wave-24 defect, found by LOOKING rather than by any gate. |
| // ⛔ EVERY CALLBACK IS REQUIRED, never optional: an optional one the frame forgot to pass |
| // degrades to "clicking a starred thing does nothing", which is indistinguishable from |
| // "the feature was never built" and is red in no gate. |
| // ⚠ `onOpenQuery` calls the assistant's EXISTING `openBuiltView`, which already does hash |
| // + a retry ladder and returns a canceller — a second emit here would be a second answer |
| // to one question. Cancel any ladder still running, as the view path does. |
| <Lazily surface="Starred"> |
| <StarredPage |
| entries={dbEntries} |
| automations={autoTiles} |
| onOpenAutomation={(autoId) => { |
| window.location.hash = "#/automation"; |
| signal(AUTOMATION_OPEN_EVENT, { autoId }); |
| }} |
| onOpenQuery={(qid) => { |
| viewEmitCancel.current?.(); |
| viewEmitCancel.current = openBuiltView(qid); |
| }} |
| onOpenView={openViewInDatabase} |
| /> |
| </Lazily> |
| ) : route === FEEDBACK_ROUTE ? ( |
| // ⭐⭐ WAVE 35 · T06 (owner item 13, R8, contract C6, wiring W2) — the three account |
| // surfaces. All three take NO props, on purpose and per B's published contract: Usage |
| // reads everything from `GET /usage`, Feedback's confirmation is inline, and |
| // Subscription must not hide itself (two places deciding one thing is how they disagree). |
| // ⛔ `tsc` CANNOT ENFORCE THESE THREE MOUNTS, precisely because the components take no |
| // props — so the `verify_wiring` W2 row is the only wall between "the page exists" and |
| // "the page is reachable". Wave 23 shipped THREE finished routers with no mount line, |
| // 404-dead behind entirely green gates. [[reachable-is-not-the-same-as-built]] |
| <Lazily surface="Feedback"><FeedbackPage /></Lazily> |
| ) : route === USAGE_ROUTE ? ( |
| <Lazily surface="Usage credits"><UsagePage /></Lazily> |
| ) : route === SUBSCRIPTION_ROUTE ? ( |
| // ⛔ C6's SECOND HALF, and the whole reason the row's absence is not enough: a hidden |
| // menu row whose route still answers is not hidden. Anyone who types the hash or follows |
| // an old bookmark walks in. Same constant as the menu, so the two cannot disagree. |
| session.user.tenant === NO_SUBSCRIPTION_TENANT ? ( |
| <div className="shell-placeholder"> |
| <h1>Not available</h1> |
| <p>This workspace does not have a subscription page.</p> |
| </div> |
| ) : ( |
| <Lazily surface="Subscription"><SubscriptionPage /></Lazily> |
| ) |
| ) : route === CONNECTORS_ROUTE ? ( |
| // ⭐ WAVE 23 item 10 (R8, C11, wiring W23-W3) — the connectors directory. |
| // |
| // The one action the page cannot perform itself: a `manage: "keychain"` row opens |
| // Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division |
| // as every other panel here — the page knows what it wants, the frame owns the door. |
| <Lazily surface="Connectors"><ConnectorsPage onKeychain={() => setSettings("keychains")} /></Lazily> |
| ) : route === INBOX_ROUTE ? ( |
| // ⭐⭐ W32-T04 (R7, C3, wirings 1+5) — THE INBOX MODULE. Same division as Connectors: |
| // the module owns the QUESTION, the frame owns the DOOR. |
| // |
| // ⛔ `onOpenTarget` IS REQUIRED ON THE CALLEE, and that is a lesson written as a type. |
| // An optional callback the frame forgot to pass degrades to "clicking an Inbox item |
| // does nothing" — indistinguishable from "the feature was never built", and red in no |
| // gate. Required means `tsc` fails the moment this is unmounted. |
| <Lazily surface="Inbox"> |
| <InboxPage |
| onInbox={setInbox} |
| onToast={setToast} |
| /* The frame has ALREADY fetched this — it is what painted the badge the user just |
| clicked — so the module opens knowing the count instead of asserting "Nothing |
| new." while its own read is in flight (W31-T23, measured 3,280 ms live). */ |
| seed={inbox} |
| onOpenTarget={(t) => { |
| // ⭐ C3's dispatch is DATA, not a branch: `inboxModel.routeForTarget` owns the |
| // target→surface question, so this is a switch over two literals it can exhaust |
| // rather than a second copy of the topic→route table. |
| const dest = routeForTarget(t); |
| if (!dest) { |
| // ⛔ AN UNKNOWN MODULE MUST SAY SO, NEVER SILENTLY DO NOTHING. It means this |
| // tab is older than the server that sent the notification; a reader who clicks |
| // and sees no change concludes the Inbox is broken rather than that they should |
| // reload. `inboxModel.routeForTarget`'s own docstring requires this half. |
| setToast("This notification points at something this version cannot open yet. Reload to update."); |
| return; |
| } |
| // ⚠ THE ORDER IS LOAD-BEARING, and it is the old pane's rule kept verbatim: the |
| // hash is set FIRST so the surface is mounting (or already mounted) when the |
| // event arrives. Dispatching first fires into a route that does not exist yet. |
| // `signal` is `apiContract`'s helper — the same channel those surfaces' own |
| // listeners use — rather than a second hand-rolled `dispatchEvent` here. |
| if (dest.surface === "automation") { |
| window.location.hash = "#/automation"; |
| signal(AUTOMATION_OPEN_EVENT, { autoId: dest.autoId, tab: dest.tab }); |
| return; |
| } |
| window.location.hash = `#/${dest.key}`; |
| // ⛔⛔ W32 (ASK C-17) — A SINGLE EMIT HERE IS SILENTLY DISCARDED ON A COLD |
| // CLICK, and that is item 19's own failure mode surviving item 19. |
| // `CustomerGrid` guards its listener with `detail.topic !== scope` and |
| // `!views.some(v => v.id === detail.viewId)` — both CORRECT (an alert outlives |
| // its view; one grid must not react to another's event) — so an emit fired the |
| // instant the hash changes lands on a grid that has not fetched its views yet, |
| // is dropped, and there is NO ACK. The table opens, the view is not selected, |
| // and the click "worked". |
| // ⚠ The ladder is C's, already built and gated in their module (5 attempts over |
| // 0–2,600 ms; a truthy `emit` ends it early, which VIEW_OPEN never returns, so |
| // it simply runs out). Cancel any ladder still running from a previous click. |
| if (dest.viewId) { |
| viewEmitCancel.current?.(); |
| viewEmitCancel.current = retryEmit(() => { |
| signal(VIEW_OPEN_EVENT, { topic: dest.key, viewId: dest.viewId }); |
| }); |
| } |
| }} |
| /> |
| </Lazily> |
| ) : route === QUERY_ROUTE ? ( |
| // ⭐⭐ W32-T05 (R1, C5, wirings 2 and 6) — THE QUERY MODULE, where AI-built views live. |
| // |
| // ⛔ `granted` IS REQUIRED, AND IT IS ALSO WHAT MAKES THIS A LEGAL CHROME ROUTE. It is |
| // the nav entries this shell ALREADY holds from `/nav` — the set the SERVER decided this |
| // session may see — so the page makes no listing call of its own and *"a chrome route |
| // renders nothing the server did not already grant"* is true by construction. The AI |
| // therefore cannot name a database the caller was not already given: C5's permission |
| // clause is the existing wall re-used, never a second one built beside it. |
| <Lazily surface="Query"><QueryPage granted={entries} /></Lazily> |
| ) : route === ASSISTANT_ROUTE ? ( |
| // ⭐⭐ 2026-08-14 (owner item 2) — THE AI ASSISTANT, where the question is asked. |
| // |
| // ⛔ `granted` IS REQUIRED HERE FOR THE SAME REASON IT IS ON `QueryPage` ABOVE, and it |
| // is now the more load-bearing of the two: this is the surface that NAMES a database to |
| // the model. It is the nav's own entries — the set the server decided this session may |
| // see — so *"a chrome route renders nothing the server did not already grant"* stays |
| // true by construction, and the build door re-checks with the same wall regardless. |
| <Lazily surface="Assistant"><AssistantPage granted={entries} /></Lazily> |
| ) : nav.phase === "ready" ? ( |
| // ⭐ WAVE 23 item 9 (R7, C10) — HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH. |
| // |
| // ⛔ WHY `!active` AND NOT `route === HOME_ROUTE`. Three states have to land here and |
| // only one of them is the literal hash: `#/home`, an EMPTY hash, and a route that no |
| // longer resolves (a deleted table, a stale bookmark, a hand-typed key). `resolveRoute` |
| // already funnels all three to "no entry" now that `defaultRoute` returns a chrome |
| // route, so testing the literal would have left the other two on the "Nothing to show" |
| // branch — a shell that looks broken as the reward for deleting a database, which is |
| // the exact failure `removeDatabase`'s own note warns about from the other side. |
| // |
| // ⚠ AND THIS BRANCH ABSORBED WAVE 18's TENANT HERO (R4), which used to live below. |
| // That hero said "Welcome — this workspace has no databases yet" plus one create |
| // button, on precisely the state Home now owns: a ready nav with zero entries. Leaving |
| // both reachable would put two welcome messages on one screen, which is the defect the |
| // rail's own note at the empty-nav line already documents. Home's four cards ARE the |
| // hero's create door, and its empty line carries the hero's sentence (HomePage's |
| // `home-empty`, which branches on `entries.length === 0` for exactly this reason). |
| // Booked as a dated amendment in the wave doc, since it moves an R4 surface. |
| <Lazily surface="Home"> |
| <HomePage |
| // ⭐ `dbEntries`, NOT `entries` — found by LOOKING at it (wave 24 close-out visual |
| // pass), which is the only way it could have been found. The Automation SURFACE is a |
| // granted nav entry with an href, so `allDatabases` drew it a tile under the heading |
| // "Databases" — beside an "Automations" section listing the things it contains. The |
| // rail has excluded it since wave 19 R10 for exactly this reason (`dbEntries`, :1261); |
| // Home was drawing the unfiltered list. |
| // ⚠ STILL THE GRANTED NAV, so the C10 law is untouched: `dbEntries` is `entries` minus |
| // one key, not a list this frame invented. |
| entries={dbEntries} |
| recents={recents} |
| automations={autoTiles} |
| // ⭐ WAVE 24 item 14 — the templates CARD is boarded. It opens the same |
| // under-construction note the AI assistant uses, rather than the picker. |
| // ⚠ The flyout's "From a template" row is UNAFFECTED (R9 keeps it working) — see |
| // HomePage's own note on why that asymmetry is deliberate rather than a miss. |
| onTemplates={() => setAssistOpen("templates")} |
| onNewDatabase={() => openNewDb("blank")} |
| /* ⛔ `onAutomated` LEFT WITH THE CARD IT OPENED (wave 25 item 5a, R8). */ |
| onConnectors={() => { |
| window.location.hash = `#/${CONNECTORS_ROUTE}`; |
| }} |
| // The same two-part move every click-through in this frame makes: the frame owns the |
| // hash, the surface owns which automation is selected. Route, then ASK. |
| onOpenAutomation={(autoId) => { |
| window.location.hash = "#/automation"; |
| signal(AUTOMATION_OPEN_EVENT, { autoId }); |
| }} |
| /* ⭐ W35-T16 (ask B-8) — the SAME handler `StarredPage` gets, deliberately: a starred |
| view opened from Home and the same view opened from Starred must not behave |
| differently, and two copies of "route then ask" is how they would. */ |
| onOpenView={openViewInDatabase} |
| /> |
| </Lazily> |
| ) : nav.phase === "error" ? ( |
| // ⚠ WAVE 17 ITEM 4 (R6) — THE ERROR AND THE EMPTY ARE DIFFERENT SENTENCES, and this |
| // branch is what keeps them apart. `active` resolves from `entries`, which is empty |
| // while the nav is IN FLIGHT, so every first login used to land on a pane stating the |
| // single worst thing this frame could say to a new user for as long as a round trip |
| // took. WAVE 23 keeps that separation and simplifies its shape: `ready` now goes to |
| // Home above (which welcomes an empty workspace properly), so this is only ever the |
| // nav that FAILED, and the nested ternary that used to sort the two is gone. |
| <div className="shell-placeholder"> |
| <h1>Nothing to show</h1> |
| <p>The navigation service did not answer. Reload to retry.</p> |
| </div> |
| ) : ( |
| // Still asking (`loading`, or the `idle` beat before the effect runs). |
| <div className="shell-loading"> |
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" /> |
| </div> |
| )} |
| </ErrorBoundary> |
| </main> |
| </div>{/* /.shell-frame — C1's rail-and-content row, beneath the top strip */} |
|
|
| {/* The server's own confirmation of a write (X2's `toast`). In standalone |
| it is the ONLY feedback for the events whose effect the payload does |
| not yet echo back — cohort membership, list adds, folder moves. */} |
| {/* Wave 18 (owner item 3): the boarded door. |
| ⭐ 2026-08-14 — IT IS THE TEMPLATES DOOR ONLY NOW. The assistant arm is gone with the |
| boarding: that surface exists (`#/assistant`), so an "under construction" note for it |
| would be a lie about a working feature. Templates is a SEPARATE boarded door (wave 24 |
| item 14) and is deliberately left exactly as it was — the owner unblocked the assistant, |
| not the template picker. */} |
| {assistOpen ? ( |
| <div className="shell-newdb-scrim" onClick={() => setAssistOpen(false)}> |
| <div |
| className="shell-newdb shell-assist-note" |
| role="dialog" |
| aria-label="Templates" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <h2>Templates</h2> |
| <p className="shell-newdb-sub"> |
| Under construction. Curated template sets are being built and will open here in |
| a later release. |
| </p> |
| <div className="shell-newdb-actions"> |
| <button type="button" className="login-submit" onClick={() => setAssistOpen(false)}> |
| Close |
| </button> |
| </div> |
| </div> |
| </div> |
| ) : null} |
|
|
| {/* Wave 18 (C3-UT): the New-database dialog. Scrim click cancels unless mid-create. */} |
| {newDb ? ( |
| <div |
| className="shell-newdb-scrim" |
| onClick={() => { |
| if (!newDb.busy) setNewDb(null); |
| }} |
| > |
| <div |
| className="shell-newdb" |
| role="dialog" |
| aria-label="New database" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <h2>New database</h2> |
| {/* ⭐ WAVE 23 C10 — THE CHOICE. One-line rows, not paragraphs: the explanation lives |
| where the DECISION is made and stops there (R13). The dialog's old sub-line ("a |
| blank database… nothing here connects to a source") said one of these things as if |
| it were the only one, so it moved onto the row it actually describes. |
| ⭐ WAVE 25 item 5a (R8) — IT WAS A THREE-WAY CHOICE AND THE THIRD ROW IS DELETED. |
| Two rows now, and they are both genuinely modes of this dialog, which the third |
| never was: "Automated" wore no `role="radio"` because it LEFT (see its own deleted |
| note) — a row inside a `radiogroup` that could not be checked. R8 removes the |
| option; the anomaly in its markup was the shape of the problem all along. */} |
| <div className="shell-newdb-kinds" role="radiogroup" aria-label="What to create"> |
| <button |
| type="button" |
| role="radio" |
| aria-checked={newDb.mode === "blank"} |
| className={"shell-newdb-kind" + (newDb.mode === "blank" ? " is-on" : "")} |
| disabled={newDb.busy} |
| onClick={() => setNewDb({ ...newDb, mode: "blank", err: "" })} |
| > |
| <span className="shell-newdb-kind-name">Blank</span> |
| <span className="shell-newdb-kind-detail"> |
| An empty database — add fields and records once it opens. |
| </span> |
| </button> |
| <button |
| type="button" |
| role="radio" |
| aria-checked={newDb.mode === "template"} |
| className={"shell-newdb-kind" + (newDb.mode === "template" ? " is-on" : "")} |
| disabled={newDb.busy} |
| onClick={() => setNewDb({ ...newDb, mode: "template", err: "" })} |
| > |
| <span className="shell-newdb-kind-name">From a template</span> |
| <span className="shell-newdb-kind-detail"> |
| Add a curated set of views to a database you already have. |
| </span> |
| </button> |
| </div> |
| {newDb.mode === "template" ? ( |
| // WAVE 23 C12 (wiring W23-W6) — the picker. It applies views to an EXISTING |
| // database rather than making one, which is what a platform-curated template is: |
| // a set of saved views, not a table (R10). |
| <Lazily surface="Templates"> |
| <TemplatePicker |
| entries={dbEntries} |
| onToast={setToast} |
| onDone={(tableKey) => { |
| setNewDb(null); |
| window.location.hash = `#/${tableKey}`; |
| }} |
| /> |
| </Lazily> |
| ) : ( |
| <> |
| <input |
| autoFocus |
| className="shell-newdb-input" |
| placeholder="Database name" |
| value={newDb.name} |
| maxLength={60} |
| disabled={newDb.busy} |
| onChange={(e) => setNewDb({ ...newDb, name: e.target.value })} |
| onKeyDown={(e) => { |
| if (e.key === "Enter") void createDb(); |
| if (e.key === "Escape" && !newDb.busy) setNewDb(null); |
| }} |
| /> |
| {newDb.err ? <p className="shell-newdb-err">{newDb.err}</p> : null} |
| <div className="shell-newdb-actions"> |
| <button |
| type="button" |
| onClick={() => setNewDb(null)} |
| disabled={newDb.busy} |
| > |
| Cancel |
| </button> |
| <button |
| type="button" |
| className="login-submit" |
| onClick={() => void createDb()} |
| disabled={newDb.busy || !newDb.name.trim()} |
| > |
| {newDb.busy ? "Creating…" : "Create database"} |
| </button> |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| ) : null} |
|
|
| {settings ? ( |
| <Lazily surface="Settings"> |
| <SettingsModal |
| user={session.user} |
| section={settings} |
| onSection={setSettings} |
| onClose={() => setSettings(null)} |
| onUser={(u) => setSession({ phase: "authed", user: u })} |
| // Wave 15 — the nav already holds a server-filtered {key: label} for |
| // every surface this account may open, so "Your access" names its |
| // restrictions in the words the rail uses rather than in registry |
| // keys. Derived here rather than mapped in the modal so there is only |
| // one place either door learns what a module is called. |
| moduleLabels={Object.fromEntries(entries.map((e) => [e.key, e.label]))} |
| /> |
| </Lazily> |
| ) : null} |
|
|
| {/* ⭐⭐ W32-T04 / R7 — THE ALERTS POP-UP PANE IS GONE, NOT HIDDEN. It was mounted here as |
| a sibling of <main>; its surface is now the `#/inbox` route above. C's `W32-T21` deletes |
| `AlertsPane.tsx` itself and retargets `verify_alerts.py` onto the module — this frame no |
| longer imports it, which is the half that makes the file dead rather than merely unused |
| ([[artifact-with-no-importer]]). */} |
|
|
| {/* C-SHARE (items 18/23/26): the access editor — one dialog, three kinds. */} |
| {shareFor ? ( |
| <ShareDialog |
| kind={shareFor.kind} |
| id={shareFor.id} |
| label={shareFor.label} |
| /* ⭐ W33-T27 — the DATABASE a shared VIEW belongs to, which the publish control needs |
| and the share request never carried. The emitter's value wins when it sends one |
| (`CustomerGrid` is another lane's file this wave); otherwise the OPEN SURFACE's key, |
| which is the same table by construction — a view's share dialog can only be opened |
| from inside that view. ⚠ A non-view share passes nothing and the panel does not |
| render, which is the same absence a non-publishable view produces. */ |
| topic={shareFor.topic || (active ? active.key : "")} |
| me={session.user.username} |
| onClose={() => setShareFor(null)} |
| onToast={setToast} |
| /> |
| ) : null} |
|
|
| {/* C-SCHEMA: the database schema drawer, over everything but the toast. */} |
| {schemaFor ? ( |
| <SchemaDrawer schemaKey={schemaFor} onClose={() => setSchemaFor(null)} /> |
| ) : null} |
|
|
| {toast ? ( |
| <div className="shell-toast" role="status" aria-live="polite"> |
| {toast} |
| </div> |
| ) : null} |
| </div> |
| ); |
| } |
|
|