| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 } 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 { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, INBOX_ROUTE, MAX_NAV_FOLDERS, NAV_TIMEOUT_STATUS, appLink, canonicalRoute, databaseEntries, dbChipClass, 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"; |
| const HomePage = lazy(() => import("../home/HomePage")); |
| const TemplatePicker = lazy(() => import("../home/TemplatePicker")); |
| const ConnectorsPage = lazy(() => import("../connectors/ConnectorsPage")); |
| |
| |
| |
| import FormPublic from "../forms/FormPublic"; |
| 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")); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| 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[] = []; |
|
|
| 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 DbIcon() { |
| return ( |
| <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| <ellipse cx="8" cy="3.6" rx="5.3" ry="2.1" /> |
| <path d="M2.7 3.6v8.8c0 1.16 2.37 2.1 5.3 2.1s5.3-.94 5.3-2.1V3.6" /> |
| <path d="M2.7 8c0 1.16 2.37 2.1 5.3 2.1S13.3 9.16 13.3 8" /> |
| </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 DbHead({ label, icon, glyph }: { label: string; icon?: FolderIcon; glyph?: ReactNode }) { |
| return ( |
| <div className="shell-db-head"> |
| <span className={dbChipClass(icon)} aria-hidden="true"> |
| {/* The database's own mark when it has one, the cylinder when it does not — |
| the same pair the rail row draws, so the header and the nav agree. Both |
| paint in the chip's ink (the stylesheet's two overrides): `FolderMark` |
| would otherwise stroke its pastel `-deep`, which is measured against the |
| WHITE rail and disappears on its own tone. |
| |
| WAVE 23 C13 — `glyph` overrides only the FALLBACK, never a chosen mark, and |
| that ordering is the contract. Automation is not a database and must not wear |
| the cylinder; but it CAN wear a `nav_meta` icon (the rail already draws one, |
| Shell:1026), and a header that ignored it would be the one surface where the |
| rail and the frame disagreed about the same row. So: chosen icon > caller's |
| glyph > the cylinder. */} |
| {icon ? <FolderMark icon={icon} size={16} /> : (glyph ?? <DbIcon />)} |
| </span> |
| {/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself, |
| and the name of the thing you are looking at is what a heading is for. Every other |
| full-pane surface in this shell (`shell-placeholder`) already uses one, and they never |
| render together — so the page gains a heading rather than a second one. */} |
| <h1 className="shell-db-name">{label}</h1> |
| </div> |
| ); |
| } |
|
|
| |
| 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 [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]); |
|
|
| |
| |
| 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 key = window.location.hash.replace(/^#\/?/, ""); |
| const topic = |
| key === "product_data" ? "product" : key.startsWith("ut_") ? key : "customer"; |
| 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 (formToken) 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, formToken]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 | "assistant" | "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 (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 = utility.find((p) => p.key === "analyst"); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const automation = entries.find((e) => e.key === "automation" && e.kind === "native"); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const automationSlot: "row" | "pending" | "unavailable" | "silent" = automation |
| ? "row" |
| : nav.phase === "loading" || nav.phase === "idle" |
| ? "pending" |
| : nav.phase === "error" |
| ? "unavailable" |
| : nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0 |
| ? "unavailable" |
| : "silent"; |
| 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 ? ( |
| <button |
| type="button" |
| className="shell-nav-item shell-nav-assist" |
| onClick={() => setAssistOpen("assistant")} |
| onMouseEnter={navCollapsed ? tipEnter("AI assistant") : undefined} |
| onMouseLeave={navCollapsed ? tipLeave : undefined} |
| > |
| <SparkIcon /> |
| <span className="shell-nav-label">AI assistant</span> |
| </button> |
| ) : null} |
|
|
| {/* ⭐⭐ 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} |
| > |
| <SparkIcon /> |
| <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> |
| <CustomerGrid |
| key={active.key} |
| scope={ |
| active.key === "product_data" |
| ? "product" |
| : active.key.startsWith("ut_") |
| ? (active.key as `ut_${string}`) |
| : "customer" |
| } |
| /> |
| </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> |
| ) : 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 assistant's boarded door. */} |
| {assistOpen ? ( |
| <div className="shell-newdb-scrim" onClick={() => setAssistOpen(false)}> |
| <div |
| className="shell-newdb shell-assist-note" |
| role="dialog" |
| aria-label={assistOpen === "templates" ? "Templates" : "AI assistant"} |
| onClick={(e) => e.stopPropagation()} |
| > |
| {assistOpen === "templates" ? ( |
| <> |
| <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> |
| </> |
| ) : ( |
| <> |
| <h2> |
| <SparkIcon /> AI assistant |
| </h2> |
| <p className="shell-newdb-sub"> |
| Under construction. The assistant is being trained on this workspace's data |
| model 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} |
| 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> |
| ); |
| } |
|
|