diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 3769a88..1dfc335 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -8,9 +8,9 @@ import { useEffect, useState } from "react"; import type { DomainEvent, HealthReport } from "@/domain"; import { ProjectsView } from "@/features/projects"; -import { FirstRunWizard, ProfilesSettings } from "@/features/first-run"; +import { FirstRunWizard } from "@/features/first-run"; import { AnnouncementsProvider } from "@/features/announcements"; -import { Button, Panel, Spinner, Toolbar } from "@/shared"; +import { Panel, Spinner, Toolbar } from "@/shared"; import { useGateways, shouldUseMock } from "./di"; export function App() { @@ -20,7 +20,6 @@ export function App() { const [events, setEvents] = useState([]); // First-run gating: null while loading, then true (wizard) / false (normal). const [firstRun, setFirstRun] = useState(null); - const [showSettings, setShowSettings] = useState(false); useEffect(() => { let unsub: (() => void) | undefined; @@ -89,15 +88,6 @@ export function App() { )} - {!firstRun && ( - - )} @@ -117,16 +107,9 @@ export function App() { setFirstRun(false)} /> - ) : showSettings ? ( - // Settings is a full scrollable view (its "Configure profiles" reopens - // the wizard, which can also exceed the viewport height). -
-
- -
-
) : ( - // ProjectsView owns the full remaining area (its own IDE layout). + // ProjectsView owns the full remaining area (its own IDE layout, + // single menu bar, and the AI Profiles view switch — ticket #16). )} diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index 07695c2..a57dcca 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -110,16 +110,22 @@ function renderView(project: MockProjectGateway) { ); } +/** Opens a menu-bar dropdown item (#16 chrome). */ +function openMenuItem(menu: string, item: string) { + fireEvent.click(screen.getByRole("button", { name: menu })); + fireEvent.click(screen.getByRole("button", { name: item })); +} + async function openProjectAndWorkTab(label: string) { await screen.findByText(label); - // Open the project whose root is `label`. + // Open the project whose root is `label` from the welcome projects list. const li = screen .getAllByRole("listitem") .find((node) => within(node).queryByText(label)); fireEvent.click(within(li!).getByRole("button", { name: "Open" })); await screen.findByRole("tab"); - // Switch the sidebar to the Work panel. - fireEvent.click(screen.getByRole("button", { name: "Work" })); + // Open the Work panel window from the View menu. + openMenuItem("View", "Work"); } describe("ProjectsView — LS7 conversation viewer integration", () => { @@ -165,8 +171,11 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { await project.createProject("beta", "/p/b"); renderView(project); - // Open BOTH projects as tabs (while the Projects sidebar list is visible). + // Open BOTH projects as tabs. Use the projects manager window so the list + // persists across activation (the inline welcome list is replaced by the + // grid once a project is active). await screen.findByText("/p/a"); + openMenuItem("File", "Projects…"); for (const root of ["/p/a", "/p/b"]) { const li = screen .getAllByRole("listitem") @@ -177,7 +186,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { // Make alpha the active tab, then open its conversation viewer. fireEvent.click(screen.getByRole("tab", { name: "alpha" })); - fireEvent.click(screen.getByRole("button", { name: "Work" })); + openMenuItem("View", "Work"); fireEvent.click( await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }), ); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 331b267..ed5ff68 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -1,34 +1,33 @@ /** * `ProjectsView` — the top-level project surface (L2 / L11). * - * IDE layout (full remaining height): + * IDE layout (full remaining height), reworked in ticket #16: * * ┌───────────────────────────────────────────────────────┐ - * │ PROJECT TABS [ alpha × ][ beta × ] [+] │ - * ├───────────────┬───────────────────────────────────────┤ - * │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining) │ - * │ [Proj][Agents]│ — or welcome screen when no active │ - * │ [Tmpl][Git] │ │ - * │ panel body │ │ - * └───────────────┴───────────────────────────────────────┘ + * │ PROJECT TABS [ alpha × ][ beta × ] │ + * ├───────────────────────────────────────────────────────┤ + * │ MENU BAR File │ View │ + * ├───────────────────────────────────────────────────────┤ + * │ MAIN — LayoutGrid (fills the FULL width) │ + * │ — or the projects manager / welcome when no project │ + * └───────────────────────────────────────────────────────┘ * - * The "Projects" sidebar tab always hosts the create-project form and the - * known-projects list, ensuring they are always in the DOM (required by tests). + * The former left sidebar is gone: every panel (context, work, tickets, agents, + * templates, skills, permissions, memory, git) is now reached from the **View** + * menu and shown in a chrome-level {@link FloatingWindow}, freeing the main area + * for the terminal grid. Project creation/opening lives under the **File** menu + * and, when no project is open, inline in the welcome area (so it is always + * reachable without a menu). * * Pure presentation: all behaviour comes from {@link useProjects}. Styling via - * `@/shared`; no inline styles, no `invoke()`. + * `@/shared`; no inline styles beyond z-index tokens, no `invoke()`. * - * **Test-contract** — the following query hooks from `projects.test.tsx` are - * always present in the DOM regardless of which sidebar tab is active: - * - getByLabelText("project name") / ("project root") — form inputs - * - getByRole("button", { name: "Create project" }) / "Refresh" / "Open" / "close " - * - getByRole("tablist") + getAllByRole("tab") + aria-selected — project tabs - * - getByText("No projects yet.") / "No open tabs." — empty states - * - getAllByRole("listitem") — known-projects list - * - getByRole("alert") — error display + * **Test-contract** — the create-project form + known-projects list are in the + * DOM whenever no project is active (welcome area) or when the Projects window is + * open; the project tab bar (`role="tablist"`) is always present. */ -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; @@ -41,13 +40,25 @@ import { PermissionsPanel } from "@/features/permissions"; import { ProjectWorkStatePanel } from "@/features/workstate"; import { TicketsView } from "@/features/tickets"; import { ConversationViewer } from "@/features/conversations"; +import { ProfilesSettings } from "@/features/first-run"; import { GitPanel, GitGraphView } from "@/features/git"; -import { Button, Input, Panel, Tabs, cn } from "@/shared"; +import { + Button, + FloatingWindow, + Input, + MenuBar, + Panel, + Tabs, + zIndex, + type FloatingWindowSize, + type MenuBarMenu, +} from "@/shared"; import { useGateways } from "@/app/di"; import { ProjectContextPanel } from "./ProjectContextPanel"; import { useProjects } from "./useProjects"; -type SidebarTab = +/** A panel reachable from the menu bar; each opens in a floating window. */ +type PanelId = | "projects" | "context" | "work" @@ -59,18 +70,33 @@ type SidebarTab = | "memory" | "git"; -const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ - { id: "projects", label: "Projects" }, - { id: "context", label: "Context" }, - { id: "work", label: "Work" }, - { id: "tickets", label: "Tickets" }, - { id: "agents", label: "Agents" }, - { id: "templates", label: "Templates" }, - { id: "skills", label: "Skills" }, - { id: "permissions", label: "Perms" }, - { id: "memory", label: "Memory" }, - { id: "git", label: "Git" }, -]; +/** Human titles for each panel's floating window (also its aria-label). */ +const PANEL_TITLE: Record = { + projects: "Projects", + context: "Project context", + work: "Work state", + tickets: "Tickets", + agents: "Agents", + templates: "Templates", + skills: "Skills", + permissions: "Permissions", + memory: "Memory", + git: "Git", +}; + +/** Window width preset per panel (content-heavy panels get more room). */ +const PANEL_SIZE: Record = { + projects: "md", + context: "lg", + work: "lg", + tickets: "lg", + agents: "lg", + templates: "lg", + skills: "lg", + permissions: "md", + memory: "lg", + git: "lg", +}; interface BackgroundTaskToast { id: string; @@ -97,8 +123,12 @@ export function ProjectsView() { const { system } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); - const [sidebarTab, setSidebarTab] = useState("projects"); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // Which panel's floating window is open (null = none). Replaces the sidebar. + const [openPanel, setOpenPanel] = useState(null); + // Top-level view switch (#16): when true, the main area shows the AI Profiles + // settings instead of the project surface. The single menu bar stays visible + // so the user can toggle back from Settings → AI Profiles. + const [showSettings, setShowSettings] = useState(false); // The active layout (id + kind), reported by LayoutTabs — the single source of // truth. `kind` decides whether the main area is the terminal grid or the git // graph view. @@ -166,6 +196,220 @@ export function ProjectsView() { if (picked !== null) setRoot(picked); } + // Opening a conversation viewer takes over the main area — close any panel + // window so it doesn't obscure the viewer (LS7). + function openConversation(conversationId: string) { + setViewerConversationId(conversationId); + setOpenPanel(null); + } + + // Switch the active project from the always-visible selector: activate its + // open tab if present, otherwise open it (adds a tab). Leaving Settings so the + // project surface is shown. + function selectProject(projectId: string) { + setShowSettings(false); + if (vm.openTabs.some((t) => t.id === projectId)) { + vm.activateTab(projectId); + } else { + void vm.openProject(projectId); + } + } + + // ── Menus ────────────────────────────────────────────────────────────── + const viewItems: { id: PanelId; label: string }[] = [ + { id: "context", label: "Context" }, + { id: "work", label: "Work" }, + { id: "tickets", label: "Tickets" }, + { id: "agents", label: "Agents" }, + { id: "templates", label: "Templates" }, + { id: "skills", label: "Skills" }, + { id: "permissions", label: "Perms" }, + { id: "memory", label: "Memory" }, + { id: "git", label: "Git" }, + ]; + // Always-visible project selector (left of the menus): shows the active + // project and lists every known project so the user can switch without opening + // the Projects window (#16 UX pass). + const projectMenu: MenuBarMenu = { + id: "project", + label: `Projet : ${active?.name ?? "—"}`, + items: + vm.projects.length === 0 + ? [ + { + id: "none", + label: "No projects yet", + disabled: true, + onSelect: () => {}, + }, + ] + : vm.projects.map((p) => ({ + id: p.id, + label: p.name, + active: p.id === active?.id, + onSelect: () => selectProject(p.id), + })), + }; + const menus: MenuBarMenu[] = [ + projectMenu, + { + id: "file", + label: "File", + items: [ + { + id: "projects", + label: "Projects…", + active: openPanel === "projects", + onSelect: () => setOpenPanel("projects"), + }, + ], + }, + { + id: "view", + label: "View", + items: viewItems.map((it) => ({ + id: it.id, + label: it.label, + active: openPanel === it.id, + onSelect: () => setOpenPanel(it.id), + })), + }, + { + id: "settings", + label: "Settings", + items: [ + { + id: "ai-profiles", + label: showSettings ? "Close AI Profiles" : "AI Profiles", + active: showSettings, + onSelect: () => { + setShowSettings((v) => !v); + setOpenPanel(null); + }, + }, + ], + }, + ]; + + // The create-project form + known-projects list. Rendered inline in the + // welcome area (no active project) or inside the Projects floating window. + const projectsManager: ReactNode = ( +
+
+

+ New project +

+ setName(e.target.value)} + /> +
+ setRoot(e.target.value)} + className="flex-1" + /> + +
+
+ + +
+
+ + + {vm.projects.length === 0 ? ( +

No projects yet.

+ ) : ( +
    + {vm.projects.map((p) => ( +
  • + + {p.name} + {p.root} + + +
  • + ))} +
+ )} +
+
+ ); + + // Body of the currently-open panel window (null ⇒ no window). + function renderPanel(panel: PanelId): ReactNode { + if (panel === "projects") return projectsManager; + if (!active) { + return ( +

Open a project to use this panel.

+ ); + } + switch (panel) { + case "context": + return ; + case "work": + return ( + + ); + case "tickets": + return ; + case "agents": + return ; + case "templates": + return ; + case "skills": + return ; + case "permissions": + return ; + case "memory": + return ( +
+ + +
+ ); + case "git": + return ; + } + } + + // Projects manager renders inline in the welcome area only when it is not + // already shown in the floating window (avoids duplicate form inputs). + const showInlineProjects = !active && openPanel !== "projects"; + return (
{/* ── Error alert ── */} @@ -193,279 +437,96 @@ export function ProjectsView() { )}
- {/* ── IDE body: sidebar + main ── */} -
- {/* ── Collapsed sidebar: a thin rail with an expand button ── */} - {sidebarCollapsed && ( - - )} + {/* ── Menu bar (replaces the former left sidebar) ── */} + - {/* ── Sidebar ── */} - - - {/* ── Main: terminal grid or git graph (fills remaining height) ── */} -
- {active && viewerConversationId ? ( - setViewerConversationId(null)} + /> + ) : active ? ( + <> + setViewerConversationId(null)} + onActiveLayoutChange={setActiveLayout} /> - ) : active ? ( - <> - - {activeLayoutKind === "gitGraph" ? ( - + ) : ( + + )} + + ) : ( +
+
+ {showInlineProjects ? ( + projectsManager ) : ( - +

+ Select or create a project to get started. +

)} - - ) : ( -
-

Select or create a project to get started.

- )} -
-
+ + )} + + + {/* ── Panel floating window ── */} + {openPanel && ( + setOpenPanel(null)} + > + {renderPanel(openPanel)} + + )} + + {/* ── Background-task toasts (above floating windows) ── */} {taskToasts.length > 0 && ( -
+
{taskToasts.map((toast) => ( + , + ); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("renders a titled modal dialog at the floatingWindow z-index", () => { + render( + +

body

+
, + ); + const dialog = screen.getByRole("dialog", { name: "My panel" }); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + // The backdrop carries the frozen z-index token. + const backdrop = dialog.parentElement as HTMLElement; + expect(backdrop.style.zIndex).toBe(String(zIndex.floatingWindow)); + }); + + it("moves focus into the window on open (focus-trap entry)", () => { + render( + + + + , + ); + // The first focusable element (the Close button in the header) receives focus. + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "close Panel" }), + ); + }); + + it("wraps Tab from the last focusable back to the first (focus-trap)", () => { + render( + + + , + ); + const closeBtn = screen.getByRole("button", { name: "close Panel" }); + const onlyBtn = screen.getByRole("button", { name: "only" }); + + // Focus the last focusable, press Tab ⇒ wraps to the first (close button). + onlyBtn.focus(); + fireEvent.keyDown(document, { key: "Tab" }); + expect(document.activeElement).toBe(closeBtn); + + // Shift+Tab from the first wraps to the last. + closeBtn.focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(onlyBtn); + }); + + it("closes on Escape, backdrop click, and the Close button", () => { + const onClose = vi.fn(); + render( + +

body

+
, + ); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + + const dialog = screen.getByRole("dialog"); + fireEvent.mouseDown(dialog.parentElement as HTMLElement); // backdrop + expect(onClose).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByRole("button", { name: "close Panel" })); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + it("does not close on clicks inside the panel body", () => { + const onClose = vi.fn(); + render( + + + , + ); + fireEvent.mouseDown(screen.getByRole("button", { name: "inside" })); + fireEvent.click(screen.getByRole("button", { name: "inside" })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("restores focus to the opener when closed", () => { + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)}> +

body

+
+ + ); + } + render(); + const opener = screen.getByRole("button", { name: "opener" }); + opener.focus(); + fireEvent.click(opener); + // Window open, focus moved inside. + expect(document.activeElement).not.toBe(opener); + // Close via Escape ⇒ focus returns to the opener. + fireEvent.keyDown(document, { key: "Escape" }); + expect(document.activeElement).toBe(opener); + }); +}); + +describe("MenuBar (#16)", () => { + const menus = [ + { + id: "view", + label: "View", + items: [ + { id: "a", label: "Agents", onSelect: vi.fn() }, + { id: "b", label: "Git", onSelect: vi.fn() }, + ], + }, + ]; + + it("opens a dropdown on click and fires the item's onSelect, then closes", () => { + const onSelect = vi.fn(); + render( + , + ); + // Item hidden until the menu opens. + expect(screen.queryByRole("button", { name: "Agents" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "View" })); + fireEvent.click(screen.getByRole("button", { name: "Agents" })); + + expect(onSelect).toHaveBeenCalledTimes(1); + // Menu closes after selection. + expect(screen.queryByRole("button", { name: "Agents" })).toBeNull(); + }); + + it("closes the open menu on Escape", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "View" })); + expect(screen.getByRole("button", { name: "Agents" })).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("button", { name: "Agents" })).toBeNull(); + }); + + it("puts the dropdown at the menuDropdown z-index", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "View" })); + const dropdown = screen.getByRole("menu", { name: "View" }); + expect(dropdown.style.zIndex).toBe(String(zIndex.menuDropdown)); + }); +}); diff --git a/frontend/src/shared/ui/FloatingWindow.tsx b/frontend/src/shared/ui/FloatingWindow.tsx new file mode 100644 index 0000000..4aed8fe --- /dev/null +++ b/frontend/src/shared/ui/FloatingWindow.tsx @@ -0,0 +1,154 @@ +/** + * FloatingWindow — the shared modal-overlay primitive (ticket #16, gate G1). + * + * Consolidates the `fixed inset-0 role="dialog" aria-modal` pattern that was + * hand-rolled in ~7 features. Mounted at the **chrome level** (ProjectsView/App), + * never inside LayoutGrid/LeafView (G5). It provides: + * - a focus-trap (mandatory: xterm captures keyboard focus, so a window opened + * over a terminal must steal *and* trap focus, then restore it on close), + * - Escape-to-close and backdrop-click-to-close, + * - the frozen `floatingWindow` z-index token (50). + * + * Purely presentational: the caller owns `open`/`onClose` state and the body. + * Rendered as `null` while closed so the body (and its hooks) only run when the + * window is actually open. + */ + +import { useEffect, useRef, type ReactNode } from "react"; + +import { cn } from "../lib/cn"; +import { Button } from "./Button"; +import { zIndex } from "./zIndex"; + +/** Width presets; height is capped at 80vh with an internal scroll region. */ +export type FloatingWindowSize = "sm" | "md" | "lg" | "xl"; + +const SIZE_CLASS: Record = { + sm: "max-w-sm", + md: "max-w-lg", + lg: "max-w-2xl", + xl: "max-w-4xl", +}; + +/** Tabbable-element selector used by the focus-trap. */ +const FOCUSABLE = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export interface FloatingWindowProps { + /** Whether the window is shown. Rendered as `null` when closed. */ + open: boolean; + /** Accessible dialog title, shown in the header. */ + title: string; + /** Called on Escape, backdrop click, or the Close button. */ + onClose: () => void; + /** Width preset (default "md"). */ + size?: FloatingWindowSize; + children: ReactNode; +} + +export function FloatingWindow({ + open, + title, + onClose, + size = "md", + children, +}: FloatingWindowProps) { + if (!open) return null; + return ( + + {children} + + ); +} + +/** + * The mounted window. Split out so the focus-trap effect and the body only run + * while `open` — mounting/unmounting on `open` gives clean focus save/restore. + */ +function FloatingWindowBody({ + title, + onClose, + size, + children, +}: Omit) { + const dialogRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const node = dialogRef.current; + // Move focus into the window (first focusable, else the dialog itself). + const first = node?.querySelector(FOCUSABLE); + (first ?? node)?.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== "Tab" || !node) return; + const focusables = Array.from( + node.querySelectorAll(FOCUSABLE), + ); + if (focusables.length === 0) { + // Nothing focusable inside: keep focus on the dialog container. + e.preventDefault(); + node.focus(); + return; + } + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + return ( +
{ + // Backdrop click closes; clicks inside the panel don't reach here. + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ {title} + +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/shared/ui/MenuBar.tsx b/frontend/src/shared/ui/MenuBar.tsx new file mode 100644 index 0000000..10d5026 --- /dev/null +++ b/frontend/src/shared/ui/MenuBar.tsx @@ -0,0 +1,126 @@ +/** + * MenuBar — a top-of-window bar of dropdown menus (ticket #16). + * + * Replaces the former left sidebar tab strip with a classic IDE-style menu bar: + * each top-level menu opens a dropdown of items; an item runs its `onSelect` + * (typically opening a {@link FloatingWindow} or toggling a panel) and closes + * the menu. One menu is open at a time; Escape or an outside click closes it. + * + * Dropdowns use the frozen `menuDropdown` z-index token (40). Menu items are + * plain ` + {open && ( +
+ {menu.items.map((item) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/shared/ui/zIndex.ts b/frontend/src/shared/ui/zIndex.ts index 43fafa6..00cacd9 100644 --- a/frontend/src/shared/ui/zIndex.ts +++ b/frontend/src/shared/ui/zIndex.ts @@ -6,11 +6,10 @@ * mounted at the **chrome level** (ProjectsView/App), never inside * LayoutGrid/LeafView (G5). * - * ⚠️ PROVISIONAL (lot B / ticket #18): created here because lot A (ticket #16) - * introduces the canonical `shared/ui/zIndex.ts` (+ `FloatingWindow`) in - * parallel. The values below MUST match the frozen scale; at merge, keep a - * single module (drop this one if lot A's is richer). See the sprint scoping - * note `ui-rework-sprint-scoping-contracts`. + * Consumers: `FloatingWindow` (floatingWindow), `MenuBar` dropdowns + * (menuDropdown), `TicketPicker` (floatingWindowNested), background-task toasts + * (toast). Introduced by lot B (#18) and adopted as the canonical scale by lot A + * (#16). See the sprint scoping note `ui-rework-sprint-scoping-contracts`. */ export const zIndex = { /** Dropdown menus / popovers anchored to a control. */