<!-- source: openspec/current/linkable-ui.md -->

---
title: Linkable UI
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: URL-state overlay system — every meaningful UI state is encoded in the URL. Owns URL grammar, OverlayHost and DialogModule contracts.
---
# Linkable UI — capability spec

> URL-state overlay system. Every meaningful UI state — open dialog, active wizard step, deep-linked edit form — is encoded in the URL so it survives refresh, share, and login redirect. This file owns the URL grammar, `OverlayHost` contract, `DialogModule` contract, component taxonomy, and accessibility baseline. Implementation paths live in `packages/ui/src/overlay/`. Cross-references to [`ui.md`](ui.md) for primitives and [`design-components.md`](design-components.md) for component anatomy.
>
> Change source: `openspec/archive/2026-05-10-add-url-state-overlay-system/` (proposal + design).

---

## Where it lives

```
packages/ui/src/overlay/
├── types.ts              ← DialogModule, defineDialog, OverlaySize, …
├── overlay-host.tsx      ← <OverlayHost />
├── use-dialog.ts         ← useDialog() hook
├── announce.ts           ← announce(msg, urgency)
├── live-regions.tsx      ← #a11y-status + #a11y-alert mount component
├── responsive-dialog.tsx ← Sheet wrapper placeholder (Phase 5+)
├── fallbacks.tsx         ← DialogSkeleton, DialogError, DialogNotFound
└── index.ts              ← barrel export

packages/ui/src/components/
└── sonner-wrapper.tsx    ← hardened Toaster + toast() override

apps/studio/src/overlay/
├── registry.ts           ← maps id → lazy module imports
└── (mounted in app/(auth-walled)/layout.tsx)

apps/studio/src/modules/<feature>/
└── <thing>.dialog.tsx    ← autonomous module per action

apps/web/src/overlay/registry.ts   ← smaller surface (privacy banner, contact form)
```

Subpath export: `@rapoport-studio/ui/overlay` (barrel). All public types and components ship from here.

---

## Public surface

| Export | Kind | Purpose |
|---|---|---|
| `OverlayHost` | React component | Mounts once per app in root layout; reads URL, dispatches to module |
| `defineDialog` | helper function | Identity function — type-safe factory for `DialogModule` objects |
| `useDialog` | React hook | `open(id, params)`, `update(params)`, `close(opts?)` for trigger sites |
| `announce` | function | Write to persistent live regions; only API for SR status messages |
| `LiveRegions` | React component | Renders `#a11y-status` + `#a11y-alert` elements at app root |
| `DialogModule<S>` | TypeScript type | Shape every module must satisfy |
| `OverlaySize` | TypeScript type | `'sm' | 'md' | 'lg' | 'xl'` — module preferred width |
| `InitialFocusHint` | TypeScript type | `'first-field' | 'cancel' | 'title' | 'close'` |
| `OverlayFormFactor` | TypeScript type | `'dialog' | 'sheet'` |
| `DialogRegistry` | TypeScript type | `Record<string, () => Promise<{ default: DialogModule }>>` |

The `sonner-wrapper.tsx` component replaces direct `Sonner` imports in apps: `@rapoport-studio/ui/components/sonner-wrapper`.

---

## URL grammar

### Reserved keys

| Key | Cardinality | Type | Owner | Purpose |
|---|---|---|---|---|
| `dialog` | 0 or 1 | string (registry enum) | `<OverlayHost>` | Active Dialog-form-factor module (D-USOS-1) |
| `sheet` | 0 or 1 | string (registry enum) | `<OverlayHost>` (Phase 5+) | Active Sheet-form-factor module (D-USOS-1). Mutually exclusive with `dialog`. |
| `d_<key>` | 0..N | string \| int \| json | active Dialog module | Module-declared params, validated by module's Zod schema |
| `s_<key>` | 0..N | string \| int \| json | active Sheet module | Module-declared params (Phase 5+) |
| `next` | 0 or 1 | URL-encoded relative path | auth flow | Post-login redirect target; validated server-side |
| `auth` | 0 or 1 | literal `ok` | auth flow (one-shot) | Signals just-returned-from-login; host announces then strips (D-USOS-19) |
| `<page-keys>` | 0..N | varies | page | Filters, sort, pagination, tabs — host never touches these |

If both `?dialog=` and `?sheet=` are set simultaneously, the host logs a console warning and renders `dialog` only (D-USOS-2).

### Companion-key conventions

| Key | Type | Use |
|---|---|---|
| `d_id` / `s_id` | string (UUID or slug) | Entity reference for edit/view modules |
| `d_step` / `s_step` | int (1-indexed) | Wizard step |
| `d_mode` / `s_mode` | `create \| edit \| view` | Module variant when one component handles multiple modes |
| `d_tab` / `s_tab` | string | Sub-tab inside a complex module (rare) |

### What does NOT live in URL

- `AlertDialog` confirm inside a parent module — local React state; not a shareable destination (D-USOS-2)
- Toast (Sonner) — transient visual notification only
- Popover / Tooltip / HoverCard / DropdownMenu / ContextMenu — anchored to triggers; transient
- Form dirty state, validation errors, focus position — transient UI state

---

## Contracts

### `DialogModule` shape

```ts
export type DialogModule<Schema extends z.ZodTypeAny = z.ZodTypeAny> = {
  /** URL-identifier: ?dialog=<id>. Stable; renaming breaks shared links. */
  id: string
  formFactor: OverlayFormFactor
  size: OverlaySize
  title: string
  titleVisuallyHidden?: boolean
  /** Default 'title'. Destructive AlertDialog → 'cancel'. */
  initialFocus?: InitialFocusHint
  /** Zod schema for d_*-prefixed URL params. Host validates before render. */
  params: Schema
  Component: ComponentType<{
    params: z.infer<Schema>
    close: (opts?: { pushTo?: string }) => void
  }>
}
```

Modules are created with `defineDialog(m)` (identity; provides type inference). See `design.md § 3` for the full type definition and an annotated reference module (`invite-member.dialog.tsx`).

### Module ownership split

| Concern | Module owns | Host owns |
|---|---|---|
| Title text | yes (via `title`) | wires to `<DialogTitle>`, applies `VisuallyHidden` if requested |
| Field validation, data fetch, save, toast, redirect | yes | — |
| Local `AlertDialog` confirm | yes | — |
| Backdrop, focus trap, Esc, `aria-modal`, animation | — | yes |
| URL param validation | declares Zod schema | runs `.safeParse`; renders `DialogError` on failure |
| Width / size | declares `size` preference | applies `sm:max-w-*` Tailwind class |

### `<OverlayHost>` API

Accepts one prop: `registry: DialogRegistry`. Mount once in the root layout of each app, outside any conditional:

```tsx
<OverlayHost registry={registry} />
```

Internal behavior:
- Reads `?dialog=` / `?sheet=` via `nuqs` `useQueryStates` (D-USOS-6)
- Resolves module via `registry[id]()` — lazy import, `<Suspense>` + `<ErrorBoundary>` wrapping
- Defers mount one tick via `useDeferredValue` so `<main>` and document title settle for AT (D-USOS-7 sequence)
- `pointer-events` watchdog: clears `document.body.style.pointerEvents` when no overlay is active (D-USOS-17)
- Auth flag: when `?auth=ok` present, calls `announce()` polite, then strips flag (D-USOS-19)

Size classes: `sm → sm:max-w-sm`, `md → sm:max-w-lg`, `lg → sm:max-w-2xl`, `xl → sm:max-w-4xl`.

### `useDialog` hook

```ts
const { open, update, close } = useDialog()

open(id, params?)      // setQuery({ dialog: id, ...d_prefixed(params) }, { history: 'push' })
update(params)         // setQuery(d_prefixed(params), { history: 'push' })
close(opts?)           // clears dialog + all d_* keys; or router.push(opts.pushTo)
```

Trigger sites call `open()` from explicit user gestures only (click, Enter, submit). No `open()` inside `onChange` (WCAG 3.2.2; ESLint rule enforced — D-USOS-3 rationale).

### Open/close API table

| Operation | Call | URL effect | History |
|---|---|---|---|
| Open create | `open('create-contract')` | `?dialog=create-contract` | push |
| Open edit | `open('edit-contract', { id: 'abc' })` | `?dialog=edit-contract&d_id=abc` | push |
| Wizard step | `update({ step: 2 })` | `?d_step=2` | push |
| Replace dialog | `open('confirm-archive', {...})` | swaps `dialog` + clears old `d_*` | push |
| Close | `close()` | clears `dialog` + all `d_*` | push |
| Close + redirect | `close({ pushTo: '/contracts/<id>' })` | pathname changes | push |

---

## Component taxonomy

### Component matrix

| Component | URL-bound? | Form factor | Use for |
|---|---|---|---|
| **Dialog** ⭐ | yes (`?dialog=`) | centered modal | Create / edit / share / atomic action from anywhere |
| **AlertDialog** | local state only | centered narrow | Destructive confirm inside parent module |
| **Sheet** (Phase 5+) | yes (`?sheet=`) | side panel | Inspect/edit while keeping list context visible |
| **Drawer (Vaul)** | — | bottom sheet | **EXCLUDED** (D-USOS-12) |
| **Popover** | local state | anchored to trigger | Date picker, color picker, small filter |
| **DropdownMenu** | local state | anchored | Fixed action menu (kebab ⋮, avatar) — not filterable lists |
| **ContextMenu** | local state | at cursor | Right-click / long-press on rows (opt-in) |
| **Command (cmdk)** | typically transient | centered | Searchable list, command palette |
| **Tooltip** | — | anchored | Text-only descriptions; never interactive (D-USOS-14) |
| **HoverCard** | — | anchored | Preview that **duplicates focusable link content** (D-USOS-13) |
| **Sonner toast** | — | corner | Transient notification; action toasts `duration: Infinity` (D-USOS-16) |
| **NavigationMenu** | — | top bar | Marketing nav (`apps/web` only) |
| **Tabs** | page-level `?tab=` | in-flow | Section switching within a page |
| **Menubar** | — | — | **Not used** — no desktop-app surface |

### Decision tree

```
Need to confirm a destructive action?
  → AlertDialog (local state, inside parent module)

Need to add or edit content from anywhere on the platform?
  → Dialog (?dialog= URL slot)

Need to inspect/edit while keeping list context visible?
  → Sheet (?sheet= URL slot)  [Phase 5+]
  → fall back to Dialog in MVP

Small anchored interactive thing (color, date, filter)?
  → Popover

Action menu attached to a button?
  → DropdownMenu

Right-click actions on a list row?
  → ContextMenu (opt-in per surface)

Searchable list / command palette?
  → Command (cmdk)

Hover-only description (icon button label)?
  → Tooltip — no interactive children

Hover-only preview (mention, link)?
  → HoverCard — content MUST duplicate focusable link

Notification after action?
  → Sonner toast — action toasts: duration: Infinity
```

### Hard lint rules

- **Tooltip**: no interactive children (`<button>`, `<a>`, `<input>`, etc.). Icon buttons need `aria-label` separately. Custom ESLint rule.
- **HoverCard**: every piece of unique content reachable via a focusable link without hover. ESLint + PR template check.
- **Vaul**: `vaul` imports blocked everywhere (`eslint-plugin-import` `no-restricted-imports`).
- **`*.dialog.tsx`**: importable only from `registry.ts` (`no-restricted-paths`).
- **Direct `sonner` import**: blocked outside `packages/ui/src/components/sonner-wrapper.tsx`.
- **`setQuery` in `onChange`**: blocked by custom rule; explicit `// allow-context-change` escape hatch for the one known exception.

---

## Accessibility baseline

### Phase 1 infrastructure deliverables

Before any overlay module lands:

1. **Two persistent live regions** in root layout — never conditionally mounted (D-USOS-10):
   ```html
   <div id="a11y-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only" />
   <div id="a11y-alert"  role="alert"  aria-live="assertive" aria-atomic="true" class="sr-only" />
   ```
   `announce(msg, urgency)` is the only write path — no ad-hoc `aria-live` in components.

2. **Stable landmark** `<main id="page-main" tabindex="-1">` in every layout. `SidebarInset` already renders `<main>` — extend to include `id` and `tabIndex`.

3. **Skip link** as first focusable element before header, pointing to `#page-main`.

4. **`<DirectionProvider dir={dir}>`** wraps the app, reading locale from i18n. Sheet (Phase 5+) uses logical sides via wrapper (D-USOS-11).

5. **`pointer-events` watchdog** in `<OverlayHost>` (D-USOS-17).

6. **`pnpm.overrides`** pins one `@radix-ui/react-focus-scope` version across the workspace (D-USOS-18).

7. **Forced-colors-mode CSS** in `packages/ui/src/styles/globals.css`:
   ```css
   @media (forced-colors: active) {
     [role="dialog"], [role="alertdialog"] { border: 1px solid CanvasText; }
     :focus-visible { outline: 2px solid CanvasText; outline-offset: 2px; }
   }
   ```

8. **Reduced-motion reset**:
   ```css
   @media (prefers-reduced-motion: reduce) {
     ::view-transition-group(*),
     ::view-transition-old(*),
     ::view-transition-new(*) { animation: none !important; }
   }
   ```

9. **Sonner wrapper** (`sonner-wrapper.tsx`): action-toast `duration: Infinity`, `z-[100]` above Dialog backdrop, focus returns to `triggerRef` on action click (D-USOS-16).

10. **Lint rules** in `eslint.config.mjs`: isolation rules (§ Hard lint rules above) + no `setQuery` in `onChange`.

### WCAG 2.2 mapping

| SC | Level | What it means |
|---|---|---|
| 2.1.1 Keyboard | A | Every trigger is `<button>` or `<a>`. No `div+onClick`. |
| 2.1.2 No Keyboard Trap | A | Esc closes top-most dialog only. Radix handles. |
| 2.1.4 Character Key Shortcuts | A | Single-char shortcuts only with modifier or when no input focused. |
| 2.2.1 Timing Adjustable | A | Sonner action-toasts → `duration: Infinity` (D-USOS-16). |
| 2.3.3 Animation from Interactions | AAA | `prefers-reduced-motion` reduces to ≤100ms fade. |
| 2.4.3 Focus Order | A | On open: `initialFocus` hint (D-USOS-8). On close: trigger or `#page-main`. |
| 2.4.7 Focus Visible | AA | `:focus-visible` ring not suppressed inside overlays. |
| 2.4.11 Focus Not Obscured | AA | Sonner offset; focused field scrolls into view. |
| 2.4.13 Focus Appearance | AAA | `ring-2 ring-ring` with verified 3:1 contrast. |
| 3.2.1 / 3.2.2 On Focus / On Input | A | URL writes only from explicit user gestures (D-USOS-3). |
| 3.3.1 Error Identification | A | Validation errors via `role="alert"` or `aria-describedby`. |
| 3.3.4 Error Prevention | AA | Destructive operations require AlertDialog confirm. |
| 4.1.2 Name, Role, Value | A | `DialogTitle` mandatory; `VisuallyHidden` if not visually shown (D-USOS-9). |
| 4.1.3 Status Messages | AA | `announce()` writes to `#a11y-status` / `#a11y-alert` (D-USOS-10). |

### A11y risks specific to URL-driven UI

| ID | Risk | Mitigation |
|---|---|---|
| R1 | Trigger absent on deep-link → focus restore broken | Track `document.activeElement` at open time; fallback `#page-main` |
| R2 | Browser back closes dialog → same focus gap | Same fallback path |
| R3 | URL change is WCAG 3.2.2 «context change» | `setQuery` only from explicit gestures; ESLint rule |
| R4 | Next.js RouteAnnouncer silent on query-only changes | Custom `announce()` called from `<OverlayHost>` |
| R5 | `aria-hidden` background + body-portal toast = silent | Sonner at root, never inside Dialog tree; `z-[100]` |
| R6 | Stacked dialogs break focus-scope with duplicate module | `pnpm.overrides` pins one `@radix-ui/react-focus-scope` (D-USOS-18) |
| R7 | `pointer-events: none` leak after Popover-in-Dialog | `useEffect` watchdog in `<OverlayHost>` (D-USOS-17) |
| R8 | Auth redirect lands user inside modal with no context | `?auth=ok` → `announce()` polite → strip flag (D-USOS-19) |
| R9 | Char-key shortcuts violate WCAG 2.1.4 | Only with modifier (`Cmd+K`) or when no input focused |
| R10 | Vaul a11y bugs | Excluded entirely (D-USOS-12) |

### Module a11y checklist

> **Dialog frame** — this checklist applies to `*.dialog.tsx` modules. For other frames see [`forms.md § Frame catalog`](forms.md#-frame-catalog).

Every Dialog-frame module passes before merge:

- [ ] Title is meaningful — for edit modules, includes entity name («Edit contract: Acme Subscription»)
- [ ] `initialFocus` declared (default `'title'`; explicit if different) (D-USOS-8)
- [ ] Mandatory `<DialogTitle>` (visible or `VisuallyHidden`) (D-USOS-9)
- [ ] Form errors via `role="alert"` or field `aria-describedby`
- [ ] Submit button: spinner + `aria-busy` while pending
- [ ] Cancel and X close routes both work
- [ ] Keyboard-only path tested (Tab through fields, Enter submits, Esc closes)
- [ ] No interactive content inside Tooltip; HoverCard duplicates link content
- [ ] axe-Playwright test passes

---

## Lifecycle flows

### Create (Dialog, no autosave)

| # | Action | URL | Module |
|---|---|---|---|
| 0 | List page | `/contracts` | — |
| 1 | Click [+ New] → `open('create-contract')` | `/contracts?dialog=create-contract` | mounting (lazy import + skeleton) |
| 2 | Module loaded | unchanged | mounted, default values; focus per `initialFocus` |
| 3 | User fills | unchanged | dirty; inline validation on blur |
| 4a | Save → success → `close()` | `/contracts` | unmounted; toast «Contract created» |
| 4b | Save → success + redirect → `close({ pushTo: '/contracts/<id>' })` | `/contracts/<id>` | unmounted; direct nav |
| 4c | Save → error | unchanged | error state; focus on first invalid field |

### Edit (Dialog, fetch on mount)

URL-bound page params (`?filter=active&sort=value`) survive the entire edit lifecycle. Host only writes `dialog` + `d_*`. Zod validates `d_id` before the module mounts; 404 from data fetch renders «Not found» body inside the dialog frame.

### Wizard (multi-step, single Dialog)

Each step advances via `update({ step: N }, { history: 'push' })`. Browser back restores previous step via popstate. Shared state across steps lives in module-local React state (or `d_data=<json>` when reload-resilience is needed — see Q-USOS-C in `proposal.md`).

### Auth-redirect with deep-link

1. User clicks email link → `/contracts?dialog=edit-contract&d_id=abc`
2. Middleware detects no session → redirect to `/login?next=%2Fcontracts%3F…`
3. Magic link → `/auth/callback?code=xxx&next=…`
4. Server validates `next` (must start `/`; no `//`; same-origin strict), exchanges code, redirects to `/contracts?dialog=edit-contract&d_id=abc&auth=ok`
5. `<OverlayHost>` sees `?auth=ok` → `announce('Signed in. Edit Contract dialog opened.', 'polite')` → strips flag
6. Dialog opens deferred one tick; focus per `initialFocus` (D-USOS-19)

### Browser back

`popstate` → `nuqs` sees no `?dialog=` → `<OverlayHost>` closes → `DialogContent.onCloseAutoFocus` returns focus to `triggerRef.current` or `#page-main` (deep-link fallback).

---

## Test matrix

Every overlay PR runs the following in CI:

| Test | Tool | Expectation |
|---|---|---|
| Cold deep-link load | Playwright | URL `?dialog=…&d_id=…` loads; dialog opens; focus inside |
| Browser back closes | Playwright | popstate closes dialog; focus on `#page-main` |
| Esc on stacked dialog | Playwright | AlertDialog inside Dialog: Esc closes only AlertDialog |
| Tab cycle at top of stack | Playwright | Focus does not leak to lower layer |
| Reduced motion | Playwright + `emulateMedia: prefers-reduced-motion` | Animations ≤100ms fade |
| RTL flip | Playwright + `<DirectionProvider dir="rtl">` | Sheet `inline-end` renders on left (Phase 5+) |
| axe-core scan | `@axe-core/playwright` | 0 critical, 0 serious violations |
| Forced colors | Playwright + `emulateMedia: forced-colors: active` | Visible borders + focus rings |
| Invalid `d_*` params | Playwright | Generic error state inside dialog; no crash |
| 404 on entity | Playwright (mock fetch) | «Not found» body inside dialog; no crash |
| Auth redirect round-trip | Playwright (mock auth) | After login, dialog opens; `?auth=ok` stripped; announce fired |

Shared fixture: `tests/overlay/fixture.ts` — helpers `openDialog(id, params)`, `expectFocusInDialog()`, `expectFocusOnLandmark()`, `axeScan()`.

---

## Boundaries

### Imports allowed

| From | To | Status |
|---|---|---|
| `@rapoport-studio/ui/overlay` | app code | yes |
| `@rapoport-studio/ui/components/sonner-wrapper` | app code | yes (replaces direct Sonner) |
| `*.dialog.tsx` | `registry.ts` only | lint-enforced |
| `vaul` | anywhere | blocked |
| `sonner` (raw) | `sonner-wrapper.tsx` only | lint-enforced |

### Migration strategy

Existing modal call-sites are **not retro-fitted in this change**. Phase 5 migrates them one at a time:
1. Carve feature content into `<feature>.dialog.tsx` via `defineDialog`
2. Register in `registry.ts`
3. Replace trigger with `useDialog().open(id, params)`
4. Run test matrix; ship as separate PR

Each migration is a small, reviewable PR. The contract proves itself on the reference module (`invite-member`, Phase 4) before broad application.

---

## Conventions

All 20 locked decisions, one line each:

| ID | Decision |
|---|---|
| D-USOS-1 | `?dialog=` and `?sheet=` are reserved platform-level keys; module params use `d_*` / `s_*` prefixes |
| D-USOS-2 | One URL-bound overlay slot at a time; `AlertDialog` confirms stay local React state |
| D-USOS-3 | Dialog (centered) is the default MVP form factor; Sheet deferred Phase 5+; Drawer (Vaul) excluded |
| D-USOS-4 | Modal contents are autonomous `DialogModule`s that know nothing about their opener |
| D-USOS-5 | Module file convention `*.dialog.tsx`, importable only from `registry.ts`; ESLint enforced |
| D-USOS-6 | `nuqs` v2 is the platform-wide primitive for URL-bound state (overlays first, then filters/sort/pagination) |
| D-USOS-7 | Phase 1 establishes the a11y baseline (live regions, landmarks, skip link, watchdogs) before any overlay module lands |
| D-USOS-8 | Every module declares an `initialFocus` hint; default `'title'`; destructive AlertDialog `'cancel'` |
| D-USOS-9 | Mandatory `<DialogTitle>` on every overlay surface; wrapped in `VisuallyHidden` when not visually shown |
| D-USOS-10 | Two persistent live regions mounted at app root, never conditionally; `announce(msg, urgency)` is the only write API |
| D-USOS-11 | `<DirectionProvider>` at app root; Sheet API uses logical sides (`side="inline-end"`) mapped to physical for Tailwind |
| D-USOS-12 | `vaul` / `Drawer` excluded from `@rapoport-studio/ui`; ESLint blocks all `vaul` imports |
| D-USOS-13 | `HoverCard` content must duplicate a reachable focusable link; unique content there is inaccessible to keyboard/SR users |
| D-USOS-14 | `Tooltip` must not contain interactive content and must not be the only path to information on touch |
| D-USOS-15 | `DropdownMenu` reserved for fixed action menus; filterable lists go through `Command` (cmdk) |
| D-USOS-16 | Sonner wrapper enforces `duration: Infinity` for action-toasts, `z-[100]` above Dialog backdrop, focus return on action click |
| D-USOS-17 | `pointer-events` watchdog in `<OverlayHost>`: force-clear `body.style.pointerEvents` when no overlay is mounted |
| D-USOS-18 | `pnpm.overrides` pins a single `@radix-ui/react-focus-scope` version across the workspace |
| D-USOS-19 | Auth redirect post-login announce: `?auth=ok` one-shot → `announce()` polite → strip flag from URL |
| D-USOS-20 | View Transitions stays Phase 6+, behind `experimental.viewTransition` flag and `prefers-reduced-motion`; same-route overlay open/close uses Motion/CSS |

---

## Out of scope

- Sheet form factor and side-panel modules — Phase 5+
- Drawer / bottom-sheet pattern — deferred (D-USOS-12); re-evaluate via separate change
- Existing modal migration — Phase 5 only; contract stabilizes on reference module first
- View Transitions rollout — Phase 6+, feature-flagged, cross-route only
- Filter / sort / pagination URL state — `nuqs` ships as dep but applying it to list pages is a follow-up change
- Storybook for overlay modules — useful, separate
- Real-time collaboration cursors inside overlays — unrelated capability
- Wizard data hand-off across sessions (beyond `d_*` params) — separate problem; server-draft path is Q-USOS-C

---

## Open questions

- **Q-USOS-A** — Should `apps/web` carry the full `<OverlayHost>` or only the a11y baseline? Default: same host in both apps for consistency; revisit if bundle impact is measurable.
- **Q-USOS-B** — First reference module candidate: `invite-member`. Swap if a higher-priority feature surfaces during Phase 2–3.
- **Q-USOS-C** — Wizard state across reloads: `d_data=<json>` in URL for v1; promote to server-side drafts when a wizard demonstrably needs cross-session persistence.
- **Q-USOS-D** — ESLint enforcement of `*.dialog.tsx` isolation: try `no-restricted-paths` first; escalate to custom rule only if patterns slip past.
- **Q-USOS-E** — Reduced-motion duration: 100ms fade default; instant at OS preference override level, not platform default.

---

## Worked examples

> Reference `*.dialog.tsx` modules, intended as templates when adding new
> registry entries. Each example pairs the dialog module with its registry
> entry shape; the conventions captured here ratify in `apps/studio/src/
> overlay/registry.ts` and the `*.dialog.tsx` modules they reference.

### `discovery` — fetch-on-mount, single-canvas-scoped intake

> Applied 2026-05-15 from change `move-canvas-top-panels-to-dialogs`.

```ts
// apps/studio/src/modules/canvas-sessions/discovery.dialog.tsx
const paramsSchema = z.object({ canvas: z.string().min(1) });

export default defineDialog({
  id: "discovery",
  formFactor: "dialog",
  size: "lg",
  title: "Discovery",
  initialFocus: "first-field",
  params: paramsSchema,
  Component: DiscoveryDialog, // loads context via server action on mount
});

// apps/studio/src/overlay/registry.ts
"discovery": () =>
  import("@/modules/canvas-sessions/discovery.dialog").then((m) => ({
    default: m.default as unknown as DialogModule,
  })),
```

URL slot: `?dialog=discovery&d_canvas=<canvasId>`. The dialog body fetches
its own state on mount via `loadDiscoveryDialogContextAction(canvasId)`
rather than receiving a snapshot through props — this keeps the registry
entry signature uniform across modules and avoids tunnelling the page-
level wizard state through the overlay system.

### `bind-telegram` — owner-gated, defence-in-depth empty state

> Applied 2026-05-15 from change `move-canvas-top-panels-to-dialogs`.

```ts
// apps/studio/src/modules/canvas-sessions/bind-telegram.dialog.tsx
const paramsSchema = z.object({ canvas: z.string().min(1) });

export default defineDialog({
  id: "bind-telegram",
  formFactor: "dialog",
  size: "md",
  title: "Bind Telegram",
  initialFocus: "first-field",
  params: paramsSchema,
  Component: BindTelegramDialog, // body renders empty for non-owner
});
```

URL slot: `?dialog=bind-telegram&d_canvas=<canvasId>`. Visibility is
gated **twice**: the pill in `<CanvasHeaderStrip>` is hidden for non-
owners (primary gate), and `loadBindCandidatesAction(canvasId)` returns
an empty list for non-owners so a direct URL hit renders an empty body
instead of leaking the participant roster. This double-gate is the
canonical pattern for owner-only dialogs.

---

## § Frame catalog cross-reference

> Applied 2026-05-13 from change `openspec/archive/2026-05-13-add-module-autonomy-standard/`.

This file's `§ Module a11y checklist` covers the **Dialog frame** only. The authoritative per-frame a11y table lives in:

**[`forms.md § Frame catalog`](forms.md#-frame-catalog)**

Key differences: focus trap (Dialog/Sheet yes; others no), dirty-guard prompt (`<AlertDialog>` overlay vs inline), live-region (`#a11y-status` vs toast-only for inline-embed), heading source (`<DialogTitle>` vs real `<h2>`).

Use `focusStrategyFor(frame)`, `dirtyGuardPresentationFor(frame)`, `announceForFrame(frame, msg)` from `@rapoport-studio/ui/overlay`.

---

## Cross-references

- [`ui.md`](ui.md) — implementation paths for Dialog, Sheet, Sonner, and other overlay primitives; package boundaries; `@rapoport-studio/ui/overlay` subpath export.
- [`design-components.md`](design-components.md) — component anatomy and a11y baseline rows for Dialog / AlertDialog / Sheet; reference to this file for system-level contract.
- [`forms.md`](forms.md) — four-layer module contract; per-frame a11y table; ESLint enforcement policy.
- [`platform.md`](platform.md) — capability map; «URL = state of UI» hard rule; Telegram canvas surface deep-link expectations.
- [`web.md`](web.md) — root layout `<OverlayHost>` mount requirement.
- [`studio-workspace/README.md`](studio.md) — root layout `<OverlayHost>` mount requirement; auth-walled layout wiring.
- `openspec/archive/2026-05-10-add-url-state-overlay-system/design.md` — full implementation detail: type definitions, `<OverlayHost>` skeleton, lifecycle sequence diagrams.
