---
name: liquid-glass-modal-and-surface-primitive
classification: feature
success_metric: |
  Two new primitives (`<GlassSurface>` and `<Modal>`) ship under
  `components/ui/`; all ~15 ad-hoc modals + dialogs in `components/`
  are migrated to `<Modal>`; 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 `<Modal>`. **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 `<Modal>`
  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
`<Modal>` primitive, one backdrop recipe, fifteen migrations.

`<GlassSurface>` 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). `<Modal>` is
implemented in terms of `<GlassSurface>` 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 `<GlassSurface tint="low" rim="subtle"
    elevation="pronounced" />`.
  - 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 `<Modal>`:

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 `<Modal>` 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

- `<Button>`, `<Input>`, `<SearchBar>` primitives — sub-convoy #3.
- Layout / MobileNavigation glass — sub-convoy #4.
- Card surface glass — sub-convoy #5.
- Dropdown primitive (the UserProfileDropdown ad-hoc menu in Layout) —
  may be tempting, but defer to #4 since Layout owns that surface.

## Roles invoked

1. `role-architect` — primitive API design (especially the focus-trap
   hook decision), brief decomposition.
2. `role-a11y-auditor` — primitive ARIA contract review BEFORE
   implementer starts (gate 1 dependency).
3. `role-implementer` — multiple briefs (see Multitask dispatch).
4. Post-PR audit fleet — `/multitask role-reviewer +
   role-design-system-auditor + role-a11y-auditor`.

## Architecture (ratified 2026-06-03)

**Primitives:**
- `components/ui/GlassSurface.js` — `forwardRef` composable surface. Props: `as`, `tint` (low/mid/high), `rim` (none/subtle/pronounced/ember-subtle/ember-pronounced), `elevation` (flat/ambient/pronounced), `blur` (low/mid/high). Composes the canonical token surface.
- `components/ui/Modal.js` — `<Modal>` primitive consuming `<GlassSurface>` for the panel. Built-in scrim + backdrop blur (`--modal-scrim` + `blur(--glass-blur-high)`), built-in title + close button, focus trap, ESC + backdrop close, body-scroll lock. Props: `open`, `onClose`, `title`, `description`, `size`, `closeOnBackdrop`, `closeOnEsc`, `initialFocusRef`, `hideCloseButton`.
- `lib/use-focus-trap.js` — homegrown hook (~60 LOC, no dep). Active-when-open, restores focus on close, Tab+Shift-Tab cycling within container.
- `components/ui/index.js` — barrel export.

**Test plan:** `test/components/Modal.test.js` — 10 cases covering open/close render, ARIA shape (role=dialog, aria-modal, labelledby, describedby), ESC + closeOnEsc gate, backdrop click + closeOnBackdrop gate, built-in close button, hideCloseButton, body-scroll lock + restore.

## Briefs

- **Brief 1 (shipped 2026-06-03):** Primitives + 4 reference modal migrations (ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal). Tests pass 10/10. Vitest 94/94 green. Lint 0 errors.
- **Brief 2 (queued for follow-up):** Sweep remaining 11 modals — CollectionSelectionModal, UploadImageModal, CollectionsEditModal, CollectionsSuccessModal, CollectionEditModal, CardDetailDeckModal, ScanDisambiguationDialog, plus inline modals in PageView components. Mechanical migration following the 4-reference pattern: replace outer fixed-backdrop div with `<Modal>`; replace inner panel container with the Modal body; rely on Modal's built-in title + close. Inner color cleanup (hardcoded Tailwind grays/blues) is out of scope here — that's #3 + #8.

## Todos

- [ ] Architect: primitive API + brief decomposition + focus-trap
      hook decision (homegrown vs `focus-trap` package)
- [ ] A11y auditor: ARIA contract review (gate-1 dep)
- [ ] Brief 1 — `<GlassSurface>` + `<Modal>` primitives + tests +
      migrate 2 reference modals (`ShareModal`, `CollectionDeleteModal`
      — small + diverse)
- [ ] Brief 2 — migrate modals 3–7 (Collections cluster)
- [ ] Brief 3 — migrate modals 8–10 (CardDetail cluster + Upload)
- [ ] Brief 4 — migrate modals 11–13 (Scanner / Settings cluster)
- [ ] Post-PR audit per brief

## Decisions to ratify (architect)

1. **Focus-trap implementation** — homegrown `useFocusTrap` hook vs
   `focus-trap` package (one small dep). Recommended: homegrown if the
   ARIA-correct shape fits in ~60 LOC; the package if not. Either way,
   `tabbable`-style focusable-element enumeration must handle
   `disabled`, `hidden`, `tabindex="-1"`, and elements inside Shadow DOM
   (unlikely needed here).
2. **Body-scroll lock approach** — `overflow: hidden` on `<body>` vs
   `inert` attribute on siblings vs a dedicated package. Recommended:
   `overflow: hidden` + `padding-right` compensation for the scrollbar.
3. **Backdrop fade-in transition** — duration + easing. Recommended:
   180ms ease-out for backdrop, 220ms cubic-bezier(0.16, 1, 0.3, 1)
   spring for the panel (Apple-style overshoot dampened).
4. **`fullscreen-on-mobile` breakpoint** — `768px` (Tailwind `md`) is
   the existing mobile pivot in the codebase. Confirm.
5. **Trigger-focus restoration when trigger is unmounted** — fall back
   to `document.body`. Confirm.
6. **`ScanDisambiguationDialog.js`** — is it a true modal or an inline
   dialog? Architect inspects + decides whether to fold or leave inline.

## Acceptance criteria

1. `<GlassSurface>` + `<Modal>` exist under `components/ui/`.
2. `test/components/Modal.test.js` passes 7+ assertions (per § Scope).
3. All ~15 modals listed in § Scope are migrated.
4. Every migrated modal:
   - Has `role="dialog"` + `aria-modal="true"` + `aria-labelledby`.
   - Traps focus.
   - Closes on ESC.
   - Restores focus on close.
   - Backdrop blurs the page behind (the user's core ask).
5. Lint + vitest + smoke green.
6. Visual-diff baselines re-seeded on Linux post-merge.
7. `.cursor/rules/ui-and-theming.mdc` § "Common UI patterns to reuse"
   updated: Modal row now points at `components/ui/Modal.js`, not the
   three ad-hoc modal files.

## CI impact

| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires (every brief). |
| `visual-diff.yml` | **Fires + LOUD** — `components/**` matches paths; modals change shape. Re-seed baselines on Linux post-each-brief. |
| `lint` | Fires. |
| `test:` (vitest) | Fires + **new 7+ assertions in `Modal.test.js`** lock the primitive's contract. |
| New grep gates | Consider a `forbidden-ad-hoc-modal-backdrop` lint or grep gate post-sweep: forbid `className="fixed inset-0 .* bg-(black|white)"` in `components/**` and `pages/**`. Architect's call. |

## Known constraints

- **No third-party UI library.** `headlessui` / `radix-ui` were
  considered (see `.convoys/ship-readiness.md` § Role-design-system-
  auditor: *"Use `headlessui` or `radix-ui`'s Dialog to get focus
  management for free."*). Architect should re-evaluate:
  - **Pros of headlessui**: free focus-trap, free ARIA, well-tested.
  - **Cons**: adds a runtime dependency, styled by Tailwind variants
    only (we use CSS variables for color — friction).
  - **Recommended default**: homegrown for v1 (smaller surface, no
    dep), revisit if Brief 1 hits >150 LOC for the primitive itself.
- **Theme tokens** — primitives consume ONLY tokens from sub-convoy #1;
  no hardcoded hex.
- **Mobile safe-area** — `<Modal size="fullscreen-on-mobile">` must
  respect `env(safe-area-inset-bottom)` (the existing
  `.h-safe-area-inset-bottom` rule pattern).

## Multitask dispatch

```yaml
slice_dependencies:
  - brief: 1
    depends_on: []
    files:
      - components/ui/GlassSurface.js
      - components/ui/Modal.js
      - components/ui/index.js
      - lib/use-focus-trap.js
      - test/components/Modal.test.js
      - components/ShareModal.js
      - components/CollectionDeleteModal.js
      - .cursor/rules/ui-and-theming.mdc
  - brief: 2
    depends_on: [1]
    files:
      - components/CollectionSelectionModal.js
      - components/CollectionsCreateModal.js
      - components/CollectionsEditModal.js
      - components/CollectionsSuccessModal.js
      - components/CollectionEditModal.js
  - brief: 3
    depends_on: [1]
    files:
      - components/CardDetailDeckModal.js
      - components/CardDetailQuantityModal.js
      - components/UploadImageModal.js
  - brief: 4
    depends_on: [1]
    files:
      - components/ScanDisambiguationDialog.js
      - components/OCRSettings.js
      - components/ManaSymbolSettings.js
```

**After Brief 1 merges:** `/multitask role-implementer briefs 2, 3, 4`
(disjoint file sets; safe).

Post-PR audit per brief:

```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```

Group id: `audit-liquid-glass-modal-<brief>-<pr>`.

## Out of scope follow-ups

- **`forbidden-ad-hoc-modal-backdrop`** CI gate — see § CI impact.
  Surface as a separate small convoy if the architect decides not to
  fold it into Brief 1.
- **Dropdown primitive** — `<Popover>` / `<Menu>` shape for
  `Layout.js`'s UserProfileDropdown. Defer to sub-convoy #4.
- **Toast / Notification primitive** — out of scope (no toast system
  exists yet; `.convoys/ship-readiness.md` § Role-ux-reviewer flagged
  this as a separate need).
