--- name: liquid-glass-modal-and-surface-primitive classification: feature success_metric: | Two new primitives (`` and ``) ship under `components/ui/`; all ~15 ad-hoc modals + dialogs in `components/` are migrated to ``; modal backdrops blur the page behind them (the user's core ask); focus-trap + ESC-to-close + ARIA-correct shape is uniform; lint + vitest + smoke + visual-diff all green per brief. skip: [] status: in-progress-brief-1-merged created: 2026-06-03 conductor_started: 2026-06-03 brief_1_merged: 2026-06-03 depends_on: - liquid-glass-design-tokens umbrella: liquid-glass-redesign --- # Convoy: liquid-glass-modal-and-surface-primitive Sub-convoy #2 of the `liquid-glass-redesign` epic. Introduces the two foundational reusable primitives + sweeps every modal in the codebase onto the new ``. **This convoy is where the "modals blur the page behind them" outcome the operator asked for actually ships.** ## Why The repo has ~15 modal / dialog components, each with its own backdrop implementation, its own focus-management (or lack thereof), its own ESC-to-close handling (inconsistent), its own ARIA shape (often missing `role="dialog"` or `aria-modal="true"`), and its own visual chrome. This was already flagged in two places: - `.convoys/ship-readiness.md` § Role-design-system-auditor: *"`CollectionSelectionModal`, `ShareModal`, `UploadImageModal` each have their own backdrop + focus-trap implementation. Extract `` primitive."* - `.convoys/ship-readiness.md` § Role-a11y-auditor: *"Focus traps in modals — none of the modals trap focus."* + *"ESC to close modals — inconsistent."* The Liquid Glass direction makes this fix mandatory because every modal now needs the same backdrop-blur effect — implementing that per-modal would be the worst possible outcome (15 places to bug-fix). One `` primitive, one backdrop recipe, fifteen migrations. `` is split out as a sibling primitive because the same "panel of glass" shape is needed in non-modal contexts (sidebar in #4, card detail in #5, dropdown in #4's UserProfileDropdown). `` is implemented in terms of `` for its panel. ## Scope ### In scope — primitives - `components/ui/GlassSurface.js` (new) — composable panel primitive. Props: `as` (default `'div'`), `tint` (`'low' | 'mid' | 'high'`), `rim` (`'none' | 'subtle' | 'pronounced' | 'ember'`), `elevation` (`'flat' | 'ambient' | 'pronounced'`), `className`, `style`, `children`. Reads tokens from sub-convoy #1. - `components/ui/Modal.js` (new) — backdrop + dialog primitive. Props: `open`, `onClose`, `title` (string, required for a11y), `description` (optional, for `aria-describedby`), `size` (`'sm' | 'md' | 'lg' | 'fullscreen-on-mobile'`), `closeOnBackdrop` (default `true`), `closeOnEsc` (default `true`), `initialFocusRef`, `children`. Implements: - Backdrop with `backdrop-filter: blur(var(--glass-blur-high))` + `background: var(--modal-scrim)`. - Inner panel uses ``. - Focus trap (proposal: small homegrown `useFocusTrap` hook in `lib/use-focus-trap.js` — no new third-party dep; architect to confirm vs `focus-trap` package). - `role="dialog"`, `aria-modal="true"`, `aria-labelledby={titleId}`, `aria-describedby={descriptionId | undefined}`. - ESC handler with cleanup on unmount. - Restores focus to the trigger on close. - Body-scroll lock while open. - `components/ui/index.js` (new) — barrel export. - `test/components/Modal.test.js` (new) — assertions: 1. Renders nothing when `open === false`. 2. Renders dialog with correct ARIA when `open === true`. 3. Calls `onClose` on ESC. 4. Calls `onClose` on backdrop click (when `closeOnBackdrop` true). 5. Does NOT call `onClose` on backdrop click when `closeOnBackdrop` false. 6. Traps focus inside the dialog (Tab cycles through focusable elements; Shift+Tab cycles backwards). 7. Restores focus to the trigger on close. ### In scope — modal sweep Migrate every modal-shaped component onto ``: 1. `components/CollectionSelectionModal.js` 2. `components/CollectionsCreateModal.js` 3. `components/CollectionsEditModal.js` 4. `components/CollectionsSuccessModal.js` 5. `components/CollectionEditModal.js` 6. `components/CollectionDeleteModal.js` 7. `components/CardDetailDeckModal.js` 8. `components/CardDetailQuantityModal.js` 9. `components/ShareModal.js` 10. `components/UploadImageModal.js` 11. `components/ScanDisambiguationDialog.js` 12. `components/OCRSettings.js` (modal-shaped; verify) 13. `components/ManaSymbolSettings.js` (modal-shaped; verify) 14. Any inline modal in `components/CollectionsPageView.js`, `components/ScannerPageView.js`, `components/CardsPageView.js`, `components/CardItem.js`, `components/CardDetailView.js` — architect inventories during architect pass. Each migrated modal: - Imports `` from `components/ui/`. - Hands off backdrop / focus / ARIA / ESC to the primitive. - Keeps its own *content* (the form, the buttons, the body copy). - Visual diff baselines are re-seeded post-merge on Linux. ### Out of scope - `