|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 { API_V1, AUTOMATION_OPEN_EVENT, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT, VIEW_OPEN_EVENT, signal } from "../apiContract";
|
| import { PageSurface } from "../pages/PageSurface";
|
| const SettingsModal = lazy(() =>
|
| import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal })));
|
| import type { SettingsSection } from "../settings/SettingsModal";
|
| import { Brand } from "./Brand";
|
| import { ErrorBoundary } from "./ErrorBoundary";
|
| import LoginPage from "./LoginPage";
|
| import { ASSISTANT_ROUTE, CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, INBOX_ROUTE, MAX_NAV_FOLDERS, NAV_TIMEOUT_STATUS, appLink, canonicalRoute, databaseEntries, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, QUERY_ROUTE, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
|
|
|
| import { DbHead, DbIcon, gridScopeFor } from "./dbFrame";
|
| const HomePage = lazy(() => import("../home/HomePage"));
|
| const TemplatePicker = lazy(() => import("../home/TemplatePicker"));
|
| const ConnectorsPage = lazy(() => import("../connectors/ConnectorsPage"));
|
|
|
|
|
|
|
| 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_MEMORY_KEY = "aios-rail-slots";
|
|
|
| function railMemory(who: string | null): Record<string, boolean> {
|
| if (!who) return {};
|
| try {
|
| const all = JSON.parse(localStorage.getItem(RAIL_MEMORY_KEY) || "{}");
|
| const mine = all && typeof all === "object" ? all[who] : null;
|
| return mine && typeof mine === "object" ? (mine as Record<string, boolean>) : {};
|
| } catch {
|
| return {};
|
| }
|
| }
|
|
|
| function rememberRail(who: string | null, slots: Record<string, boolean>) {
|
| if (!who) return;
|
| try {
|
| const raw = localStorage.getItem(RAIL_MEMORY_KEY);
|
| const parsed = raw ? JSON.parse(raw) : {};
|
| const all = parsed && typeof parsed === "object" ? parsed : {};
|
| all[who] = slots;
|
| localStorage.setItem(RAIL_MEMORY_KEY, JSON.stringify(all));
|
| } catch {
|
|
|
|
|
| }
|
| }
|
|
|
| function useHashRoute(): string {
|
|
|
|
|
|
|
| const read = () => canonicalRoute(window.location.hash.replace(/^#\/?/, ""));
|
| const [route, setRoute] = useState<string>(read);
|
| useEffect(() => {
|
| const onChange = () => 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 SearchIcon() {
|
| return (
|
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| <circle cx="7" cy="7" r="4.3" />
|
| <path d="M10.2 10.2 13.6 13.6" />
|
| </svg>
|
| );
|
| }
|
|
|
|
|
| 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 AutoIcon() {
|
| return (
|
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| <rect x="1.9" y="5.4" width="4.6" height="5.2" rx="1.1" />
|
| <rect x="9.5" y="5.4" width="4.6" height="5.2" rx="1.1" />
|
| <path d="M6.5 8h3" />
|
| </svg>
|
| );
|
| }
|
|
|
| |
| |
|
|
| function BellIcon() {
|
| return (
|
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| <path d="M8 2.4a3.5 3.5 0 0 1 3.5 3.5v2.3l1.2 2H3.3l1.2-2V5.9A3.5 3.5 0 0 1 8 2.4Z" />
|
| <path d="M6.6 12.4a1.5 1.5 0 0 0 2.8 0" />
|
| </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 PlugIcon() {
|
| return (
|
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| <path d="M6.7 9.3 9.3 6.7" />
|
| <path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
|
| <path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
|
| </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>
|
| {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();
|
| }}
|
| >
|
| 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>
|
| );
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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" });
|
|
|
|
|
|
|
|
|
|
|
|
|
| const [navEpoch, setNavEpoch] = useState(0);
|
| const [dataError, setDataError] = useState("");
|
| const [toast, setToast] = useState("");
|
|
|
|
|
| const [settings, setSettings] = useState<SettingsSection | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const [shareFor, setShareFor] = useState<ShareRequest | null>(null);
|
|
|
|
|
|
|
|
|
|
|
| const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
|
| |
| |
|
|
| const viewEmitCancel = useRef<null | (() => void)>(null);
|
| const route = useHashRoute();
|
|
|
|
|
|
|
| const formToken = formTokenOf(route);
|
|
|
|
|
|
|
| const publishToken = publishTokenOf(route);
|
| const publicToken = formToken || publishToken;
|
|
|
|
|
|
|
| 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 {
|
|
|
| }
|
| }, [navCollapsed]);
|
|
|
|
|
|
|
|
|
|
|
| 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), []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| setNavCollapsed(false);
|
| }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("");
|
| }, []);
|
|
|
|
|
|
|
|
|
| const draggingRef = useRef(false);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| const [opened, setOpened] = useState<Record<string, number>>({});
|
| const lastStamped = useRef("");
|
|
|
|
|
|
|
|
|
| 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 ?? ""));
|
| const onNavMinimize = () => setNavCollapsed(true);
|
|
|
|
|
|
|
| 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);
|
| };
|
| }, []);
|
|
|
|
|
| useEffect(() => {
|
| if (!toast) return;
|
| const t = setTimeout(() => setToast(""), 6000);
|
| return () => clearTimeout(t);
|
| }, [toast]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
| setDataError("");
|
| }, [route]);
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
| let dead = false;
|
| void me().then((user) => {
|
| if (!dead) setSession(user ? { phase: "authed", user } : { phase: "anon" });
|
| });
|
| return () => {
|
| dead = true;
|
| };
|
| }, []);
|
|
|
|
|
|
|
| const who = session.phase === "authed" ? session.user.username : null;
|
|
|
| useEffect(() => {
|
| if (who === null) {
|
| setNav({ phase: "idle" });
|
| return;
|
| }
|
| let dead = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| setNav((cur) => (cur.phase === "ready" ? cur : { phase: "loading" }));
|
|
|
|
|
|
|
| setDataError("");
|
| setToast("");
|
| void fetchNav().then((r) => {
|
| if (dead) return;
|
| if (r.ok) {
|
|
|
|
|
|
|
| const { main, utility } = splitChrome(r.pages);
|
| setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility,
|
| recents: r.recents,
|
| ...(r.empty ? { empty: r.empty } : {}),
|
|
|
|
|
|
|
| ...(r.omitted ? { omitted: r.omitted } : {}),
|
| ...(r.degraded ? { degraded: r.degraded } : {}) });
|
| }
|
|
|
|
|
| else if (r.status === 401) setSession({ phase: "anon" });
|
| else setNav({ phase: "error",
|
| ...(r.status === NAV_TIMEOUT_STATUS ? { timedOut: true } : {}) });
|
| });
|
| return () => {
|
| dead = true;
|
| };
|
| }, [who, navEpoch]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| useEffect(() => {
|
| if (nav.phase !== "ready") return;
|
| rememberRail(who, {
|
| analyst: !!findAnalyst(nav.utility),
|
| automation: !!findAutomation(nav.entries),
|
| });
|
| }, [nav, who]);
|
|
|
|
|
|
|
| const entries = nav.phase === "ready" ? nav.entries : NO_ENTRIES;
|
|
|
| const tenantEmptyState =
|
| nav.phase === "ready" && entries.length === 0 && nav.empty === "no_databases";
|
|
|
|
|
|
|
|
|
| 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]);
|
|
|
|
|
| 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;
|
| });
|
| }, []);
|
|
|
|
|
|
|
| const [dragKey, setDragKey] = useState<string | null>(null);
|
| const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
|
|
|
|
|
| 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 });
|
|
|
|
|
|
|
|
|
|
|
|
|
| const at = flyoutFrom(dbButton.current);
|
| if (at) setDbAt(at);
|
| },
|
| [commitPrefs]
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
|
|
|
|
|
| window.addEventListener("focus", pull);
|
| return () => {
|
| dead = true;
|
| window.removeEventListener("focus", pull);
|
| };
|
| }, [who]);
|
|
|
|
|
|
|
| useEffect(() => {
|
| const onCreate = (e: Event) => {
|
| const req = parseAlertCreate((e as CustomEvent).detail);
|
| if (!req) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| const topic = currentSurfaceScope();
|
| void createAlert(req.viewId, topic, req.label).then((r) => {
|
| if (!r.ok) {
|
|
|
|
|
|
|
| setToast(r.message);
|
| return;
|
| }
|
| setToast(`Alerting on "${req.label}". New records that enter it appear under Alerts.`);
|
| 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.");
|
| }, []);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const removeDatabase = useCallback(
|
| async (key: string): Promise<{ ok: boolean; error?: string }> => {
|
| const r = await deleteTable(key);
|
| if (!r.ok) return r;
|
|
|
|
|
| clearCustomersCache();
|
| setNavEpoch((e) => e + 1);
|
| setToast("Database deleted.");
|
|
|
|
|
|
|
|
|
|
|
| if (route === key) window.location.hash = "";
|
| return { ok: true };
|
| },
|
| [route]
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const [autoTiles, setAutoTiles] = useState<AutomationTile[]>([]);
|
| 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,
|
| |
| |
| |
| |
|
|
|
|
|
|
| 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]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(() => {
|
|
|
|
|
|
|
| clearCustomersCache();
|
| void logout().then(() => setSession({ phase: "anon" }));
|
| }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
|
|
| const [assistOpen, setAssistOpen] = useState<false | "templates">(false);
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: "" });
|
| }, []);
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const res = await fetch(`${API_V1}/tables`, {
|
| method: "POST",
|
| credentials: CREDENTIALS,
|
| headers: { "Content-Type": "application/json" },
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
| }
|
| 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: "" };
|
| });
|
| }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (formToken) {
|
| return <FormPublic token={formToken} />;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (publishToken) {
|
| return <PublishedView token={publishToken} />;
|
| }
|
|
|
| if (session.phase === "checking") {
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const automation = findAutomation(entries);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| type RailSlot = "row" | "pending" | "unavailable" | "silent";
|
| const railWas = railMemory(who);
|
| const railSlot = (present: boolean, remembered: string): RailSlot =>
|
| present
|
| ? "row"
|
| : nav.phase === "loading" || nav.phase === "idle"
|
| ?
|
|
|
|
|
|
|
| railWas[remembered] === false
|
| ? "silent"
|
| : "pending"
|
| : nav.phase === "error"
|
| ? "unavailable"
|
| : nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0
|
| ? "unavailable"
|
| : "silent";
|
| const automationSlot: RailSlot = railSlot(!!automation, "automation");
|
| const analystSlot: RailSlot = railSlot(!!analyst, "analyst");
|
| const dbEntries = databaseEntries(entries);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const dbQ = dbQuery.trim().toLowerCase();
|
| const shownEntries = dbQ
|
| ? dbEntries.filter((e) => e.label.toLowerCase().includes(dbQ))
|
| : dbEntries;
|
|
|
| const recents = mergeRecents(nav.phase === "ready" ? nav.recents : NO_RECENTS, opened);
|
|
|
| return (
|
| <div className="shell-root">
|
| <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}
|
| >
|
| {/* The PRODUCT brand — the same mark the Streamlit host paints, from the
|
| same generated file, so the two shells cannot drift. "Royal Imports"
|
| stays on business documents only. */}
|
| <div className="shell-side-head">
|
| {/* Owner item 2 (2026-07-31) — collapsed, the LOGO is the way back and the
|
| three-bars toggle disappears (CSS hides it). One stable element in both states
|
| so the mark can animate rather than swap; disabled while expanded, where it is
|
| brand, not control. */}
|
| <button
|
| type="button"
|
| className="shell-brand-btn"
|
| disabled={!navCollapsed}
|
| aria-label={navCollapsed ? "Expand navigation" : undefined}
|
| onMouseEnter={navCollapsed ? tipEnter("Expand navigation") : undefined}
|
| onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| onClick={() => setNavCollapsed(false)}
|
| >
|
| <Brand size={26} className="shell-brand" />
|
| </button>
|
| <button
|
| type="button"
|
| className="shell-rail-toggle"
|
| aria-label="Minimize navigation"
|
| aria-expanded={!navCollapsed}
|
| title="Minimize navigation"
|
| onClick={() => setNavCollapsed(true)}
|
| >
|
| <RailToggleIcon />
|
| </button>
|
| </div>
|
|
|
| <nav className="shell-nav">
|
| {/* ⭐ 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>
|
|
|
| {/* 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("AI assistant") : undefined}
|
| onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| >
|
| <SparkIcon />
|
| <span className="shell-nav-label">AI assistant</span>
|
| </button>
|
| ) : analystSlot === "pending" ? (
|
| /* ⭐⭐ W33-T22 (owner item 1) — THE ASSISTANT'S OWN SLOT, held from first paint.
|
| ⛔ THIS IS THE ROW THE OWNER WAS ACTUALLY REPORTING. Without it, `/nav` landing
|
| INSERTED ~34 px here and pushed Query / Inbox / Automation / Database / Connectors
|
| down — so the row a reader sees "load separately" is the one with NO gate at all
|
| (Connectors, `:1765`), which cannot arrive late and therefore must have MOVED.
|
| ⚠ Same classes as Automation's pending arm on purpose: `.shell-nav-item.is-pending`
|
| and `.shell-nav-item.is-pending .lp-spin{16px}` are GENERIC rules in index.css, and
|
| `.shell-nav-assist` applies by class, so this placeholder is the same 34 px box the
|
| real row will be. A new modifier class would need an index.css rule (B's file, C4)
|
| and would render at a different height until it got one — which is the jump again. */
|
| <div
|
| className="shell-nav-item shell-nav-assist is-pending"
|
| aria-busy="true"
|
| aria-label="AI assistant is loading"
|
| >
|
| <span className="lp-spin" aria-hidden="true" />
|
| <span className="shell-nav-label shell-nav-label--muted">AI assistant</span>
|
| </div>
|
| ) : 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">AI assistant</span>
|
| <span className="shell-nav-note-dot" aria-hidden="true" />
|
| </div>
|
| ) : 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]]. */}
|
|
|
| {/* ⭐⭐ W32-T05 (item 7, ruling R1) — QUERY, directly under the AI assistant because that is
|
| what it IS: the place the assistant's work goes. R1 — "every AI-built view lands under
|
| the Query module".
|
| ⚠ THE OWNER'S SIX-ROW ORDER IS UNCHANGED ABOVE AND BELOW IT (Home / AI assistant /
|
| Inbox / Automation / Database / Connectors, wave 24 item 1). Query is a SEVENTH row,
|
| inserted beside its own producer rather than appended — appending would have put the
|
| newest surface furthest from the thing that fills it, and re-ordering the six would
|
| have re-opened a decision the owner already made. */}
|
| <a
|
| className={"shell-nav-item shell-nav-query" + (route === QUERY_ROUTE ? " is-active" : "")}
|
| href={`#/${QUERY_ROUTE}`}
|
| onMouseEnter={navCollapsed ? tipEnter("Query") : undefined}
|
| onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| >
|
| {/* ⭐ 2026-08-14 (owner item 2) — A MAGNIFIER, not the sparkle. Verbatim: *"Change
|
| the icon for Query, it should just be a magnifying glass icon"*. The sparkle now
|
| appears exactly once in the rail, on the row that actually is the AI. */}
|
| <SearchIcon />
|
| <span className="shell-nav-label">Query</span>
|
| </a>
|
|
|
| {/* WAVE 20 item 25 (C-ALERT) — "Alerts".
|
| ⭐ WAVE 24 item 1 — IT MOVED UP, above Automation. The owner's order is
|
| Home / AI assistant / Alerts / Automation / Database / Connectors, and the
|
| two pairs that swapped are Alerts↔Automation here and Database↔Connectors
|
| below. Nothing else about these rows changed: MEASURED on staging v12, all
|
| six already render 13.8125px / w500 / Inter / rgb(32,36,51), `<a>` and
|
| `<button>` alike (wave 19 R11's reset holding). The one row in this band
|
| that differed was "+ Create new…" (12.75px / w400 / muted), and item 15a
|
| takes it out of the rail entirely — see `CreateNewRow`'s own note.
|
| ⭐⭐ W32-T04 (owner item 16, ruling R7) — IT IS A NAV LINK NOW, AND THE
|
| PARAGRAPH THIS REPLACES WAS RIGHT ON ITS PREMISE AND WRONG ON ITS
|
| CONCLUSION. Its premise — the nav is server-filtered and an undeclared
|
| surface is denied — still holds and is why Inbox is CHROME rather than a
|
| `registry.py` module (see `nav.INBOX_ROUTE`: a registry key outside a
|
| tenant's `modules` list is silently omitted in every tenant). Its
|
| conclusion, that the surface must therefore be a PANEL, does not follow:
|
| Home and Connectors are routes under the same law, because a chrome route
|
| renders nothing the server did not already grant — and `GET /notifications`
|
| is this account's own notifications, composed by the server, for this
|
| session. The owner asked for an inbox that reads like email; a dropdown
|
| that closes when you look away is the one shape that cannot. */}
|
| <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}
|
| >
|
| <BellIcon />
|
| <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>
|
|
|
| {/* WAVE 19 R10 / C3 — Automation. 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}
|
| >
|
| {/* ⚠ R8 lists Automation among the databases that get an icon
|
| ("Customer/Product/Automation/custom"); R10 moves it out of the
|
| list where the ⋯ that SETS one lives. So it DISPLAYS a chosen
|
| mark — the store and the route already accept `automation` as a
|
| key — while the entry point to choose one is the open question
|
| booked as amendment 6 for the integrator. Rendering the default
|
| glyph unconditionally would have made this the one database
|
| that could never show an icon, which is the half of R8 a client
|
| has no business deciding. */}
|
| {automation.icon ? (
|
| <span className="shell-nav-mark" aria-hidden="true">
|
| <FolderMark icon={automation.icon} size={16} />
|
| </span>
|
| ) : (
|
| <AutoIcon />
|
| )}
|
| <span className="shell-nav-label">{automation.label}</span>
|
| </a>
|
| ) : automationSlot === "pending" ? (
|
| /* ⭐ W31-T11 — THE PENDING STATE, IN AUTOMATION'S OWN SLOT. The shared spinner mark
|
| (R6: the mark, never the word "Loading…", which `verify_icons` enforces), on a row
|
| that occupies the space the real one will. `aria-busy` is what makes it a pending
|
| ROW to a screen reader rather than a decorative glyph. */
|
| <div
|
| className="shell-nav-item shell-nav-auto is-pending"
|
| aria-busy="true"
|
| aria-label="Automation is loading"
|
| >
|
| <span className="lp-spin" aria-hidden="true" />
|
| <span className="shell-nav-label shell-nav-label--muted">Automation</span>
|
| </div>
|
| ) : 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."
|
| }
|
| >
|
| <AutoIcon />
|
| <span className="shell-nav-label shell-nav-label--muted">Automation</span>
|
| <span className="shell-nav-note-dot" aria-hidden="true" />
|
| </div>
|
| ) : null}
|
|
|
| {/* ⭐ 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}
|
| className={"shell-nav-item shell-nav-db" + (dbAt ? " 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}
|
| 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 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={() => setNavCollapsed(false)}
|
| onCreateFolder={createFolder}
|
| onNewDatabase={() => {
|
| closeDbFly();
|
| openNewDb("blank");
|
| }}
|
| onFromTemplate={() => {
|
| closeDbFly();
|
| openNewDb("template");
|
| }}
|
| canFolder={entries.length > 0}
|
| />
|
| </div>
|
| </div>,
|
| document.body
|
| )
|
| : null}
|
| {/* Wave 17 item 3 (R6) — the mark, never the word. "Loading…" under an
|
| empty rail told the reader what they could already see, and it read
|
| as a nav ITEM for the beat before it vanished. */}
|
| {nav.phase === "loading" ? (
|
| <div className="shell-nav-note is-spin">
|
| <span className="lp-spin" role="status" aria-label="Loading" />
|
| </div>
|
| ) : null}
|
| {/* 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>
|
|
|
| <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();
|
| setNavCollapsed(false);
|
| }
|
| : 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.
|
| <div className="shell-db-frame">
|
| <DbHead
|
| label={active.label}
|
| glyph={<AutoIcon />}
|
| {...(active.icon ? { icon: active.icon } : {})}
|
| />
|
| <div className="shell-auto-host">
|
| <Lazily surface="Automation"><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.
|
| <div className="shell-db-frame">
|
| <DbHead
|
| label={active.label}
|
| {...(active.icon ? { icon: active.icon } : {})}
|
| />
|
| <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 === 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 });
|
| }}
|
| />
|
| </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>
|
|
|
| {/* 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>
|
| );
|
| }
|
|
|