diff --git a/AGENTS.md b/AGENTS.md
index fe2e19fc..9348b775 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,6 +73,8 @@ Prefer Platform.Bible's `platform-bible-react` components (`Button`, `Input`, `T
Size icons inside a `Button` with `size-*` (e.g. `tw:size-3`), never `h-*`/`w-*` — `buttonVariants` forces any child SVG lacking a `size-` class to `size-4`, silently overriding `h-*`/`w-*`.
+Modals go through [ModalShell](src/components/modals/ModalShell.tsx) rather than building their own overlay — it supplies the platform `Dialog`, which brings a focus trap, scroll lock, focus restore, and Escape-to-dismiss. Whether a modal passes `onClose` is the single switch governing every dismissal route: supplying it enables both Escape and outside-click, and a modal that is mid-submission passes none, so neither route can abandon in-flight work. Suppress it only for work that is genuinely being abandoned — a read-only load has nothing to abandon and leaves the modal dismissable. Modals tag their title with a `data-testid` because end-to-end tests locate them that way; never give the title an `id` instead, since that displaces the one the platform `Dialog` generates and the dialog then logs that its title is missing. The platform `Dialog` and `Popover` both render `role="dialog"` — select a modal by `[data-slot="dialog-content"]` when the two must be told apart.
+
### Styling
All UI uses Tailwind CSS (via `src/tailwind.css`). Every Tailwind class is prefixed `tw:` to avoid collisions with Platform.Bible's own styles (configured in `tailwind.config.ts`). For modifier variants the prefix comes first: `tw:hover:px-3`, not `hover:tw-px-3`.
diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx
index fa8da279..4c44d889 100644
--- a/__mocks__/platform-bible-react.tsx
+++ b/__mocks__/platform-bible-react.tsx
@@ -11,6 +11,7 @@ import {
isValidElement,
useContext,
useEffect,
+ useId,
useLayoutEffect,
useMemo,
useRef,
@@ -581,6 +582,128 @@ export function RadioGroupItem({
);
}
+/**
+ * Context carrying the {@link Dialog}'s open-state change handler and generated title id down to
+ * {@link DialogContent} and {@link DialogTitle}, mirroring how the real Radix-based component
+ * reaches its parts from the root.
+ */
+const DialogContext = createContext<{ onOpenChange?: (open: boolean) => void; titleId?: string }>(
+ {},
+);
+
+/**
+ * Stub dialog root that renders its children unconditionally. The extension mounts a modal only
+ * while it should be showing and holds `open` at `true`, so visibility needs no simulation here.
+ *
+ * Generates the title id the way the real component does, so the automatic `aria-labelledby`
+ * wiring between the surface and its heading is exercised rather than assumed.
+ */
+export function Dialog({
+ children,
+ onOpenChange,
+}: Readonly<{
+ children?: ReactNode;
+ onOpenChange?: (open: boolean) => void;
+}>): ReactElement {
+ const titleId = useId();
+ const contextValue = useMemo(() => ({ onOpenChange, titleId }), [onOpenChange, titleId]);
+ return