diff --git a/web/src/app/_ui/modal.tsx b/web/src/app/_ui/modal.tsx
new file mode 100644
index 0000000..ae8678d
--- /dev/null
+++ b/web/src/app/_ui/modal.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+
+const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])';
+
+/** Accessible modal: portal to
, ESC + backdrop close, focus trap, scroll lock,
+ * focus restore on close. Renders nothing when closed. */
+export function Modal({
+ open,
+ onClose,
+ title,
+ children,
+ wide,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title?: ReactNode;
+ children: ReactNode;
+ wide?: boolean;
+}) {
+ const panelRef = useRef(null);
+ const lastFocus = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ lastFocus.current = document.activeElement as HTMLElement | null;
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ panelRef.current?.focus();
+
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ onClose();
+ return;
+ }
+ if (e.key !== "Tab") return;
+ const nodes = panelRef.current?.querySelectorAll(FOCUSABLE);
+ if (!nodes || nodes.length === 0) return;
+ const first = nodes[0];
+ const last = nodes[nodes.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prevOverflow;
+ lastFocus.current?.focus();
+ };
+ }, [open, onClose]);
+
+ if (!open || typeof document === "undefined") return null;
+
+ return createPortal(
+ e.target === e.currentTarget && onClose()}>
+
+
,
+ document.body,
+ );
+}
diff --git a/web/src/app/design.css b/web/src/app/design.css
index 4150580..2287035 100644
--- a/web/src/app/design.css
+++ b/web/src/app/design.css
@@ -242,3 +242,35 @@ nav.contents .sep { flex: 1; }
}
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.settings-section { margin-bottom: 30px; }
+
+/* ── Modal ────────────────────────────────────────────── */
+.modal-backdrop {
+ position: fixed; inset: 0; z-index: 100;
+ background: rgba(0, 0, 0, 0.55);
+ display: flex; align-items: flex-start; justify-content: center;
+ padding: 6vh 20px; overflow-y: auto;
+ animation: modal-fade 0.14s ease-out;
+}
+.modal-panel {
+ background: var(--paper-2); border: 1.5px solid var(--rule-2); width: 100%; max-width: 560px;
+ box-shadow: 0 24px 70px -24px rgba(0, 0, 0, 0.6); margin: auto 0;
+ animation: modal-rise 0.16s ease-out;
+}
+.modal-panel.wide { max-width: 780px; }
+.modal-head {
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
+ padding: 15px 20px; border-bottom: 1px solid var(--rule);
+}
+.modal-title {
+ font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase;
+ color: var(--graphite); min-width: 0;
+}
+.modal-close {
+ background: transparent; border: 0; color: var(--graphite); cursor: pointer;
+ font-size: 0.95rem; line-height: 1; padding: 4px 6px;
+}
+.modal-close:hover { color: var(--ink); }
+.modal-body { padding: 20px; }
+@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
+@keyframes modal-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+@media (prefers-reduced-motion: reduce) { .modal-backdrop, .modal-panel { animation: none; } }