import { useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { AnchoredOverlay } from "./OverlaySurface"; import { EXPORT_FORMATS, EXPORT_LABELS } from "./export"; import type { ExportFormat } from "./export"; import type { DisplayMode, FolderIcon, GridFolder, SavedView, ViewEditMode, ViewPermissions, Viewer, } from "./types"; import { DEFAULT_FOLDER_SHAPE, DEFAULT_FOLDER_TONE, FOLDER_SHAPES, FOLDER_TONES, MAX_VIEW_USERS, VIEW_EDIT_BLURBS, VIEW_EDIT_LABELS, VIEW_EDIT_MODES, cleanFolderIcon, cleanViewPermissions, isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock, viewDisplayMode, } from "./types"; import { CREATABLE_MODES, FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS, MODE_LABELS, MODE_TONE, } from "./iconShapes"; import { FOLDER_TONE_PAINT } from "./iconShapes"; import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons"; import { ROOT_FOLDER_ID } from "./folders"; // ⭐ W32-T23 (owner item 17) — THE BELL, from the shared layer rather than redrawn here. // The alert row wore `cohortAdd` (a LIST with a plus), which names "add to a cohort" on the // one row that creates an ALERT — a borrowed mark is a small lie a menu repeats every time // it opens, and `icons.tsx`'s own `upload` header records the last time this bit. // ⚠ `MenuLabel` takes a NODE for exactly this case: a mark that is not part of the grid's // menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`), // so the bell that opens the Inbox and the bell that fills it cannot drift apart. import { BellIcon } from "../ui/icons"; import { SHARED_FOLDER_ID, groupByFolder, reorderFolderIds } from "./folders"; /* ───────────────────────────────────────────────────────────────────────────────────────── ⭐ WAVE 27 · OWNER ITEM 1 — a MARK on each "Who can edit" row. The three choices used to be three radio buttons and three sentences, which is a wall of prose at the exact moment a user is deciding who else can change their work. A mark is the part you recognise on the second visit, and Airtable's own share step leads every row with one. ⛔ WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also paints — geometry with two painters belongs there. These three have exactly ONE call site, thirty lines below, and no canvas draws them. `LockMark` and `EyeOffIcon` already carry the same argument at their definitions ("paths are inline… no header ever draws this one"). ⚠ The wave-27 ownership map puts `iconShapes.ts` in session D's fence and leaves `icons.tsx` in NOBODY's; a one-call-site component in a file this session owns has zero collision risk, and the ask to settle `icons.tsx`'s owner is in the mailbox (C-3). The tone follows the STRENGTH of the grant — neutral for the private default, blue for the open one, green for the deliberate pick — and the paint comes from the shared pastel table, never a literal, so these can never be the one place in the product with its own colours. Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only the linings". */ const PERM_TONE: Record = { personal: "neutral", collaborative: "blue", users: "green", }; /** head + shoulders, at `x`. One drawing, three uses — a second hand-drawn person is how two * marks in one panel end up different heights. */ const person = (x: number) => `M${x} 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z` + `M${x - 3.2} 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1`; const PERM_MARK: Record = { // One person: only you. personal: [person(8)], // Two, side by side and overlapping: anyone who can see the table. collaborative: [person(5.9), person(10.6)], // One person and a tick: the people you picked. users: [person(6), "M10.6 9.4l1.5 1.6 2.6-3"], }; function PermMark({ mode }: { mode: ViewEditMode }) { const paint = FOLDER_TONE_PAINT[PERM_TONE[mode]]; return ( {PERM_MARK[mode].map((d, i) => ( ))} ); } /* WAVE 20 item 18 (C-SHARE) — the system folder every view shared WITH you lands in until you file it somewhere of your own. It is the only folder id this component recognises by name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that produces it — a component-local copy of a contract id is a copy that can drift from the rule, and the rule is the half a gate can run. */ interface ViewSidebarProps { views: SavedView[]; activeViewId: string; saveState: "saved" | "saving"; onSelect: (id: string) => void; /** I14 + I17 — the flyout names the display mode and the prompt names who may edit, so * creation carries both. `permissions` is always explicit (C4: absent on create means * personal, so an omission would silently contradict the form). */ onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void; onRename: (id: string, name: string) => void; /** Persist a view's description. "" is a real value — it clears it. */ onNote: (id: string, note: string) => void; onDuplicate: (id: string) => void; onDelete: (id: string) => void; /** Cohorts this user can add to (owner item 6). Empty is fine — the menu then only offers * "New cohort…", which is how the first cohort gets created. */ lists?: { id: string; name: string }[]; /** Add the customers THIS view currently matches to a cohort. `cohortId` empty = create one * called `name`. The view need not be the active one — the caller re-runs the engine for it. */ onAddToList?: (viewId: string, cohortId: string, name: string) => void; /** * ⭐ WAVE 21 item 11 (ruling R10) — open "Select from file…" for the ACTIVE view. * * ⛔ NO ARGUMENTS, and that is the contract rather than an omission. The dialog matches * over the rows the GRID holds, which are the active view's; passing a view id here * would invite a caller to match one view's list against another's rows and report the * difference as missing records. The menu switches views first (see its own note) so * this can stay a verb with one subject. */ onSelectFromFile?: () => void; /** ⭐ WAVE-29 T25 (owner item 6) — open the Import dialog. Supplied only where records may be * added, so a locked database simply has no such row in its menu. */ onImport?: () => void; /** * Item 12 (contract C-LOCK) — lock a view to a cohort, or `null` to unlock. * * ⚠ This menu is the ONLY emitter of `cohortLock` in the client. Optional so the rail still * compiles and renders wherever the host has not accepted the key — the same posture every * other optional action here takes, and the reason a half-deployed wave degrades to "the * action is absent" rather than to "the action silently does nothing". */ onCohortLock?: (viewId: string, cohortId: string | null) => void; /** * The TENANT'S today (wave-2 item 4) — the payload's `today` string VERBATIM, seeding the * default cohort name ` · `. Never the browser clock: a viewer a day * ahead of the tenant would stamp a cohort with a date the host's own data denies * ([[date-window-vocabulary]]). Absent = the name seeds without a date. */ today?: string; /** * Wave-7 item W2 (contract C2) — export THIS view's current matches (filters + sorts, * visible fields in display order), generated client-side. Absent on tables where the * client does not hold the rows (server-windowed), so the entry never lies. */ onExport?: (viewId: string, format: ExportFormat) => void; /** * Wave-8 I11c (contract C4) — folders. All of it is OPTIONAL: a caller that * passes no `folders` and no handlers renders exactly the pre-wave-8 flat rail, * which is what keeps the cohort page (and any future rail) from having to opt * into a feature it does not want. */ folders?: GridFolder[]; /** The folder a view belongs to RIGHT NOW — resolved by the caller through * `resolveFolderId`, so a pending drag and a dangling ref are already handled * before the rail sees them. */ folderIdOf?: (viewId: string) => string | null; /** C5 (I15) — `icon` absent means the caller should send no icon at all (the folder wears * the default), NOT that it should clear one. */ onFolderCreate?: (name: string, icon?: FolderIcon) => void; onFolderRename?: (folderId: string, name: string) => void; onFolderDelete?: (folderId: string) => void; onFolderDuplicate?: (folderId: string) => void; onItemMove?: (viewId: string, folderId: string | null) => void; /** * WAVE 20 item 19 (C-FOLDER-REORDER) — the folders, in the order the user just dragged * them into. The FULL list every time, never a delta: a partial order cannot say where an * unnamed folder went, and the host has to be able to stamp `order` on all of them. * * Optional like every other folder handler here: a caller that does not pass it gets a * rail with no folder drag at all, rather than a grip that lifts a folder and drops it * back — the posture this whole component takes toward an action nobody can honour. */ onFolderReorder?: (order: string[]) => void; /** * ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — the rail's new VIEW order, whole. * * ⛔ REQUIRED, AND THE OPTIONAL VERSION OF THIS EXACT PROP IS WHY. `onFolderReorder` above * shipped optional in wave 20: its absence disabled the entire folder drag SILENTLY, every * gate stayed green, and folders simply would not move (the note at its call site in * `CustomerGrid` records it). A required prop fails `tsc` at the unmounted call site, which * is the only version of this rule a person cannot forget. */ onViewReorder: (order: string[]) => void; /** * ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — `viewId -> how many records match it RIGHT NOW`, * for the views that carry an alert. A view with no alert is ABSENT from this map, and that * absence is the whole display rule ("alert-on = badge-on"). * * ⛔ REQUIRED, and `{}` is the honest empty. An optional prop would make "no alerts on this * table" and "the caller forgot to compute them" the same state on screen — which is the * shape wave 20's optional `onFolderReorder` shipped in, and it went unnoticed for a wave. * ⚠ ABSENT vs ZERO are DIFFERENT and both are real: absent = no alert, so no badge; `0` = an * alert on a view that currently matches nothing, which is exactly the case a user wants to * see. `!== undefined` rather than a truthiness test, for that reason. */ alertCounts: Record; /** * C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller * owns the arithmetic (it holds the engine); the rail owns the confirm. * `preview` must return the DEDUPED union plus anything it could not compute, * because the amendment is explicit that a partial union is never silent. */ folderAddPreview?: (folderId: string) => { pids: number[]; counted: number; skipped: { name: string; why: string }[]; }; onFolderAddToList?: (folderId: string, cohortId: string, name: string) => void; /** * Wave-9 contract C3 (I12) — who is looking, so the lock entry appears only for the view's * creator or an admin. Absent = the entry never appears (fail-closed), which is also what * every caller that has not wired `onToggleLock` gets. */ viewer?: Viewer; /** C3 — freeze/unfreeze this view's DISPLAY MODE. The host re-checks the actor. */ onToggleLock?: (viewId: string, locked: boolean) => void; /** * ⭐⭐ WAVE 32 · T24 (owner item 17, ruling R5, contract C4) — mark / unmark a view IMPORTANT. * * ⚠ OPTIONAL, matching every other host callback on this rail: the sidebar is host-neutral and * a host that has not wired it simply does not offer the row (rather than offering one that * throws). The behaviour is not silently degraded — the row is ABSENT, which a reader can see. */ onToggleImportant?: (viewId: string, important: boolean) => void; /* wave17 R1 / C-LOCKV — `cohorts` and `onSelectCohort` are GONE from this component's surface. They carried the transient-lock selection for the retired Cohorts section; a locked view is now an ordinary member of `views` and is selected by `onSelect` like any other, which is the whole point of R1. */ /** I17 (C4) — the tenant's real account list, for the "Specific users" picker. The HOST * re-validates every name against core/users.py and drops what it cannot resolve, so this * list is a convenience, never the authority. */ userOptions?: string[]; } export default function ViewSidebar({ views, activeViewId, saveState, onSelect, onCreate, onRename, onNote, onDuplicate, onDelete, lists = [], onAddToList, onSelectFromFile, onImport, onCohortLock, today, onExport, folders, folderIdOf, onFolderCreate, onFolderRename, onFolderDelete, onFolderDuplicate, onItemMove, onFolderReorder, onViewReorder, alertCounts, folderAddPreview, onFolderAddToList, viewer, onToggleLock, onToggleImportant, userOptions = [], }: ViewSidebarProps) { /** * I14 — creating a view now always knows WHICH KIND of view is being created, because the * flyout is the only door in. `null` = not creating; a mode = the name step for that mode. */ const [creating, setCreating] = useState(null); /** I14 — the "+ Create new…" flyout's anchor. */ const [createMenu, setCreateMenu] = useState(null); const [name, setName] = useState(""); /** I17 (C4) — the create prompt's who-can-edit draft. Seeded PERSONAL, matching the host's * create-side default, so the form and the store agree before the user touches anything. */ const [permEdit, setPermEdit] = useState("personal"); const [permUsers, setPermUsers] = useState([]); const [menu, setMenu] = useState<{ viewId: string; anchor: HTMLButtonElement; } | null>(null); const [renamingId, setRenamingId] = useState(null); const [noteFor, setNoteFor] = useState<{ viewId: string; anchor: HTMLButtonElement; } | null>(null); const [noteDraft, setNoteDraft] = useState(""); const [addFor, setAddFor] = useState<{ viewId: string; anchor: HTMLButtonElement; } | null>(null); /** Item 12 (C-LOCK) — which view's cohort lock the picker is open for. */ const [lockFor, setLockFor] = useState<{ viewId: string; anchor: HTMLButtonElement; } | null>(null); const [newListName, setNewListName] = useState(""); /** W2 — the Export format pane, anchored where the "…" menu was. */ const [exportFor, setExportFor] = useState<{ viewId: string; anchor: HTMLButtonElement; } | null>(null); const exportView = exportFor ? views.find((view) => view.id === exportFor.viewId) : undefined; const addView = addFor ? views.find((view) => view.id === addFor.viewId) : undefined; const menuView = menu ? views.find((view) => view.id === menu.viewId) : undefined; /** C4 — may this viewer change the menu view's IDENTITY? Courtesy only; the host is the * wall. C3's lock toggle ANDs with this rather than replacing it: the two contracts * compose (C4 says who may edit, C3 says who may freeze the mode). */ const canEditMenuView = !!menuView && mayEditView(menuView, viewer); // I11c local UI state. Collapse is deliberately LOCAL (C4: "minimize state // local") — which folders you have open is a per-person, per-session habit, // not a property of the shared workspace. const [collapsed, setCollapsed] = useState>(new Set()); const [folderMenu, setFolderMenu] = useState<{ id: string; anchor: HTMLElement } | null>(null); const [renamingFolder, setRenamingFolder] = useState(null); const [creatingFolder, setCreatingFolder] = useState(false); /** * I15 — the folder-create draft. The name became CONTROLLED with an explicit Create button * for a reason that is not cosmetic: the old input committed on BLUR, and clicking an icon * swatch blurs the input — so an icon picker beside a commit-on-blur field would create the * folder the moment you tried to colour it. */ const [folderName, setFolderName] = useState(""); const [folderIcon, setFolderIcon] = useState({ shape: DEFAULT_FOLDER_SHAPE, tone: DEFAULT_FOLDER_TONE, }); const [confirmDelete, setConfirmDelete] = useState(null); const [addTarget, setAddTarget] = useState(null); /** The element the bulk-add pane hangs off. Anchoring to document.body * put it at the bottom of the DOCUMENT rather than beside the folder — * painted, and effectively unreachable ([[ui-invisible-to-assertions]]). * Captured from the folder menu before that menu is dismissed. */ const [addAnchor, setAddAnchor] = useState(null); const [addName, setAddName] = useState(""); const [dropTarget, setDropTarget] = useState(null); // Owner item 9 (2026-07-31) — "Find a view". Local and never persisted: a search is a // moment, not a setting. Filtering runs BEFORE grouping so a folder with no matches // simply drops out of the rail while the query is live. const [viewQuery, setViewQuery] = useState(""); // Owner item 11 — this rail is the SECOND navigation bar: it folds to the same slim strip // the shell nav does, remembered per browser under its own key. const [railShut, setRailShut] = useState(() => { try { return localStorage.getItem("aios-views-rail") === "1"; } catch { return false; } }); const toggleRail = () => setRailShut((v) => { const next = !v; try { localStorage.setItem("aios-views-rail", next ? "1" : "0"); } catch { // storage can be blocked; the fold still works for the session } return next; }); const viewNeedle = viewQuery.trim().toLowerCase(); const shownViews = viewNeedle ? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle)) : views; // ── WAVE 20 item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder the rail ────── // // ⚠ A PRIVATE dataTransfer MIME, and it is not decoration. The VIEW drag two blocks down // carries `text/plain`, and the folder groups already accept that drop ("file this view // into me"). Sharing one channel would make every folder drag look like a view drop to // the handler that fires first — so the two drags are told apart by TYPE, the same // discipline the shell nav's own drag uses, and a drop of this type anywhere // text-editable types nothing. const FOLD_DRAG_TYPE = "application/x-loopable-fold"; /** The folder being dragged, and the group its insertion line is currently drawn above. */ const [foldDrag, setFoldDrag] = useState(null); const [foldOver, setFoldOver] = useState(null); const foldsReorderable = !!folders && !!onFolderReorder; /** * Land `draggedId` before `beforeId` (null = last) and emit the new order. The arithmetic * lives in `folders.ts` so a gate can run it under node — the handler around it is DOM. * * ⚠ The order is computed from the `folders` PROP, never from the rendered groups: a live * "Find a view" query hides folders with no matches, and an order derived from what is on * screen would silently drop the hidden ones out of the sequence. */ const reorderFolders = (draggedId: string, beforeId: string | null) => { const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId); // `null` is "nothing to say" — an unknown id, or a drop that changed nothing. if (next) onFolderReorder?.(next); }; // ── ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — dragging a VIEW to reorder the rail ──────── // // The owner asked for both lists to be draggable top-to-bottom; folders already were // (wave 20 item 19) and views could only be FILED INTO a folder, never moved past each // other. This is that drag, built on the same three pieces so the two gestures cannot drift: // a private MIME, the shared `reorderFolderIds` arithmetic, and a whole-order emit. // // ⛔ THE ARITHMETIC IS REUSED, NOT COPIED. `reorderFolderIds` is id-generic — it takes a list // of ids, a dragged id and a "before" id, and every clause in it (dropped-on-itself, the // not-found fallback, the no-op return) is about SEQUENCES rather than about folders. It is // already exercised by `verify_folders.py`, and a second copy here would be a second place // for the dropped-on-itself bug that function's own comment records ([[one-evaluator-per-question]]). const VIEW_DRAG_TYPE = "application/x-loopable-view"; const [viewDrag, setViewDrag] = useState(null); const [viewOver, setViewOver] = useState(null); /** * Land `draggedId` before `beforeId` and emit the FULL order. * * ⚠ Computed from the `views` PROP, never from the rendered rows — a live "Find a view" * query hides non-matching rows, and an order derived from what is on screen would silently * drop every hidden view out of the sequence. (The folder half carries the identical note; * it is the same trap and this rail has both filters on it.) */ const reorderViews = (draggedId: string, beforeId: string | null) => { const next = reorderFolderIds(views.map((v) => v.id), draggedId, beforeId); if (next) onViewReorder(next); // `null` = an unknown id, or a drop that changed nothing }; /** * One pair of handlers per VIEW ROW, listening for the view MIME only. * * ⛔ `stopPropagation` IS LOAD-BEARING. A view row sits inside its folder group, whose own * `onDrop` reads `text/plain` and files the view into that folder. Both types are on this * drag, so without this a reorder inside a folder would ALSO re-file the view — usually into * the folder it is already in, i.e. invisible, until the day it is not. * ⚠ WHAT THIS DELIBERATELY DOES NOT DO: change the view's folder. Dropping a view onto a row * in another folder reorders it and leaves it where it lives; filing stays a drop on the * folder itself. Two gestures, two meanings, rather than one gesture guessing. */ const viewDropHandlers = (viewId: string) => ({ onDragOver: (e: React.DragEvent) => { if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "move"; setViewOver(viewId); }, onDragLeave: () => setViewOver(null), onDrop: (e: React.DragEvent) => { if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return; const dragged = e.dataTransfer.getData(VIEW_DRAG_TYPE); setViewOver(null); setViewDrag(null); if (!dragged) return; e.preventDefault(); e.stopPropagation(); reorderViews(dragged, viewId); }, }); const viewsReorderable = views.length > 1; const foldersOn = !!folders && !!folderIdOf && !!onItemMove; const groups = groupByFolder( shownViews, foldersOn ? (folders as GridFolder[]) : [], (v) => (folderIdOf ? folderIdOf(v.id) : null), /** * ⭐ WAVE 21 item 9 (R12 / contract C1) — THE CONSUMER of `shared`. * * The wire marks each granted view `shared: true` (C1); this is where that mark becomes * a place on screen. Passed unconditionally — NOT gated on `foldersOn` — because the * folder machinery is an opt-in feature of the views rail and "who shared this with me" * is not: a surface with folders switched off must still be able to show you somebody * else's view under a heading that says whose it is. */ (v) => v.shared === true ); const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined; const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null; /** * ONE pair of drop handlers per group, serving TWO drags — a view being filed into this * folder (`text/plain`) and, since wave 20 item 19, a folder being dropped in front of * this one (`FOLD_DRAG_TYPE`). They must be one pair rather than two spreads: a second * `onDragOver`/`onDrop` on the same element replaces the first, silently, and the drag * that lost would simply stop working. * * The type is read BEFORE anything else, so a folder drag never paints the "file a view * in here" fill and a view drag never draws the insertion rule. */ const dropHandlers = (folderId: string | null) => foldersOn || foldsReorderable ? { onDragOver: (e: React.DragEvent) => { if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) { if (!foldsReorderable) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setFoldOver(folderId ?? "__root__"); return; } if (!foldersOn) return; e.preventDefault(); setDropTarget(folderId ?? "__root__"); }, onDragLeave: () => { setDropTarget(null); setFoldOver(null); }, onDrop: (e: React.DragEvent) => { if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) { const dragged = e.dataTransfer.getData(FOLD_DRAG_TYPE); setFoldOver(null); setFoldDrag(null); if (!foldsReorderable || !dragged) return; e.preventDefault(); e.stopPropagation(); reorderFolders(dragged, folderId); return; } if (!foldersOn) return; e.preventDefault(); setDropTarget(null); const id = e.dataTransfer.getData("text/plain"); // ⭐⭐ W32-T27 (owner item 20) — DROPPING ON ROOT SENDS A VALUE, NOT `null`. // // `null` means "no placement", and `groupByFolder` reads a shared view with no // placement as one that arrived by grant — so dragging a shared view to the top // level put it straight back under "Shared with me". `ROOT_FOLDER_ID` is the // receiver saying they filed it here, which is a different fact and now has a // different value. // // ⚠ SENT FOR EVERY ITEM, not only shared ones, and that is deliberate: a rail that // emits one shape for a shared view and another for your own has two move paths to // keep in step, and the bug being fixed here is exactly what happens when two // layers disagree about what absence means. For an unshared view the two are // equivalent — `groupByFolder` maps the sentinel onto the same root bucket. if (id) onItemMove?.(id, folderId ?? ROOT_FOLDER_ID); }, } : {}; const noteView = noteFor ? views.find((view) => view.id === noteFor.viewId) : undefined; const create = () => { const value = name.trim(); if (!value || !creating) return; // C4: permissions are sent EXPLICITLY on create. Omitting them means 'personal' // host-side, so a user who picked "Collaborative" and got a personal view would have no // way to tell — the form said one thing and the store did the other. // Cleaned through the same rules the host applies, so an empty "Specific users" grant // collapses to 'personal' HERE too and the confirmation the user sees is the truth. onCreate(value, creating, cleanViewPermissions( { edit: permEdit, users: permUsers }, "personal", userOptions)); setName(""); setPermEdit("personal"); setPermUsers([]); setCreating(null); // WAVE 20 item 20 — the two steps share ONE panel, so the anchor outlives step 1 and // has to be released here. Left set, the flyout would spring back to the type chooser // the instant the view was created. setCreateMenu(null); }; /** Item 20 — abandon the whole flyout, from either step. Escape, an outside click and * Cancel all mean the same thing ("I am not creating anything"), so they call one * function rather than three combinations of setters — and abandoning drops the DRAFT * too, or the next "+ Create new…" would hand back a half-filled form from a decision * the user already walked away from. */ const closeCreate = () => { setCreating(null); setCreateMenu(null); setName(""); setPermEdit("personal"); setPermUsers([]); }; const createFolder = () => { const value = folderName.trim(); if (!value) return; // cleanFolderIcon returns undefined for the all-defaults pair, so a folder the user // never restyled stays byte-identical to a pre-wave-9 one (the no-churn rule). onFolderCreate?.(value, cleanFolderIcon(folderIcon)); setFolderName(""); setFolderIcon({ shape: DEFAULT_FOLDER_SHAPE, tone: DEFAULT_FOLDER_TONE }); setCreatingFolder(false); }; /** * WAVE 20 item 20 — the view door KEEPS the anchor, because the name/permissions step * now renders in the SAME anchored box the type chooser used. The old comment ("close * the flyout first, or the menu sits on top of the input") described the bug the owner * reported from the other side: the two steps of one action were drawn in two different * places, one beside the rail and one inside it, so step 2 read as an unrelated form. * One panel, two contents, one geometry. * * The FOLDER door still closes it: that path is a single step and has no second panel * to disagree with. Moving it too would be a change the ruling does not ask for. */ const startView = (mode: DisplayMode) => { setCreatingFolder(false); setCreating(mode); }; const startFolder = () => { setCreateMenu(null); setCreating(null); setCreatingFolder(true); }; /** * WAVE 20 items 18/23/26 (R10, C-SHARE) — open the access editor for a view or a * folder. * * ⚠ A WINDOW EVENT, NOT A PROP, and not for convenience. The dialog is the SHELL's * (one editor for views, folders and databases — R10 asks for one vocabulary), and * this tree is host-neutral: `customer-grid/**` must not import a shell. The frame * already listens on this channel for toasts and staleness; this is one more note on * it. The literal is spelled once here and once in `shell/shareModel.ts`, which is * where it becomes a shared constant when S3 publishes it in `apiContract.ts` * (amendment A-S4-4). * * ⛔ It degrades to NOTHING when no shell is listening (the Streamlit-era bare grid): * a dispatched event nobody hears opens no dialog and throws no error — which is the * right failure for a frame-level surface reached from a rail. */ const openShare = (kind: "view" | "folder", id: string, label: string) => { window.dispatchEvent( new CustomEvent("aios:share-open", { detail: { kind, id, label } }) ); }; const onMenuKeyDown = (event: ReactKeyboardEvent) => { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const items = [ ...event.currentTarget.querySelectorAll('[role="menuitem"]'), ]; if (!items.length) return; event.preventDefault(); const current = items.indexOf(document.activeElement as HTMLButtonElement); const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : event.key === "ArrowDown" ? (current + 1) % items.length : (current - 1 + items.length) % items.length; items[next]?.focus(); }; return (