289 lines
13 KiB
Markdown
289 lines
13 KiB
Markdown
|
|
---
|
|||
|
|
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).
|