Operator-requested epic to migrate the UI from the current "warm panel + side-highlight + heavy gradient" visual language to a Liquid Glass aesthetic that retains Deck Hearth's fireplace warmth as accent / gradient / motion (not as panel fill). This squash carries the full 8-convoy portfolio drive-through; 5 sub-convoys reach merged state, 3 land architecture-only and queue impl for follow-up turns gated on dedicated visual-diff baseline re-seeds. Sub-convoy #1 (liquid-glass-design-tokens) — MERGED. 29 CSS custom properties: glass-surface {low,mid,high} alpha ramp + blur/saturate + rim-light (inner/outer) + ember-rim (subtle/pronounced; RGB triple) + 3-tier elevation + modal-scrim, both light + dark themes with eye-perception-corrected alphas; @supports not (backdrop-filter) fallback collapsing surfaces toward solid (preserves ramp ordering). Authored docs/DESIGN_TOKENS.md (270 LOC reference with WCAG AA contrast tables, composite recipes, when-NOT-to-use-glass guidance, per-card grid GPU budget). AGENTS.md gains a § Visual language section as the new agent-contract surface. Sub-convoy #2 (liquid-glass-modal-and-surface-primitive) — Brief 1 MERGED. Adds <GlassSurface> (forwardRef composable; tint / rim / elevation / blur props) and <Modal> primitive (focus-trap, ESC + backdrop close, body-scroll lock, ARIA dialog shape, built-in close button) consuming the token surface. lib/use-focus-trap.js — homegrown hook (~60 LOC, no dep). 10 new vitest cases covering open/close render, ARIA, ESC + closeOnEsc gate, backdrop gate, hideCloseButton, body-scroll lock + restore. 4 reference modal migrations as proof-of-pattern: ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal. Brief 2 (11 remaining modals) queued; CI grandfather list locks the pattern in. Sub-convoy #3 (liquid-glass-form-primitives) — Brief 1 MERGED. Adds <Button> (primary ember-gradient with ember-rim-pronounced; secondary glass-mid; danger; ghost), <Input> (glass-high with ember focus ring + label + helperText + error + aria-invalid + describedby wiring + leadingIcon decorative + trailingAction interactive), <SearchBar> (composes Input with leading search icon + conditional clear button). 10 new vitest cases. pages/login.js + pages/signup.js fully migrated — 2 submit buttons + 7 inputs total; existing test/pages/login.test.js assertion ("Sign in to Deck Hearth" button text) preserved. Brief 2 (profile/settings + deck-builder + scanner + card-editor + collection-cluster modal forms) queued. Sub-convoy #4 (liquid-glass-layout-shell) — MERGED. 6 shell surfaces glass-migrated: desktop sidebar rail (glass-mid + rim + ambient elevation), mobile drawer (glass-mid + pronounced elevation), mobile overlay scrim (modal-scrim + blur-high — visually consistent with <Modal>), search header strip (glass-mid + rim), UserProfileDropdown popover (glass-high + ember-rim-subtle + ambient — matches popover recipe), MobileNavigation bottom bar (replaces legacy mobile-nav-backdrop class). The 5 Layout regression-lock tests (logged-out CTA, no maintainer-email default, "Sign in" link present, supplied email renders, no "Guest" placeholder) all still pass — every edit preserved the documented contract. Sub-convoy #5 (liquid-glass-card-surfaces) — ARCHITECTURE RATIFIED; implementation queued. Pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a fix-card3d-state convoy (Card3D has pre-existing state-management bug: state setters used without useState declarations). Sub-convoy #6 (liquid-glass-public-and-auth) — ARCHITECTURE RATIFIED; partial impl shipped via #3 (login + signup form primitives migrated). Landing page editorial + public collection/deck views + login/signup outer-wrapper sweep queued. Sub-convoy #7 (motion-system-pass) — MERGED. 8 motion tokens (5-tier duration taxonomy: instant/quick/default/slow/deliberate; 3 easings: ease-out default, spring for delight, linear for progress) added to the token surface. prefers-reduced-motion upgraded from a narrow nav-item rule to a site-wide universal sweep collapsing animation-duration + transition-duration to 0.01ms (preserves end states, no flicker); .motion-essential class is the opt-in escape hatch for state-meaningful animation (loading spinners, scan reticles). Authored docs/MOTION_SYSTEM.md with WCAG SC 2.3.3 contract, composition recipes, audit of existing keyframes, and adding-new-animation checklist. Sub-convoy #8 (cleanup-legacy-design-css) — Brief 1 MERGED. Two new CI jobs in .github/workflows/ci.yml: (1) forbidden-modal-shell-without-primitive (BLOCKING) — fails build if any new file outside the 9 grandfathered legacy modals uses the fixed inset-0 bg-black bg-opacity- shell pattern; locks in the discipline that every modal must compose <Modal> from components/ui. (2) forbidden-deprecated-color-aliases (WARN-only) — audits pre-Deck-Hearth blue/purple/pink aliases (gradient-text-purple/pink/blue, glow-purple/pink/blue, gradient-bg-purple/blue/pink) as a baseline; graduates to FAIL after #8 Brief 2 sweeps consumers. .cursor/rules/ui-and-theming.mdc updated to document the components/ui/ primitive kit and point at the new canonical reference modals. Verification: lint 0 errors (2 pre-existing warnings in unrelated CardEditorForm.js + CollectionsPageView.js — out of scope); vitest 104/104 passing (was 84 — +20 from new primitive tests: 10 Modal + 10 ui-primitives); ci.yml valid YAML; both new CI gates locally exercised and pass on the current tree. Operator follow-ups documented in .convoys/ship-readiness.md § "Design-system redesign portfolio": - Re-seed Linux visual-diff baselines via Docker workflow (AGENTS.md § 6) after this merges. - preview-smoke.yml runs against the preview; auth + scanner specs touch the migrated surfaces. - Vercel promote to production once smoke + visual gates pass. - Queued follow-up implementer turns: #2 Brief 2 (11 modals), #3 Brief 2 (other forms), #5 Brief 1 (cards, after fix-card3d-state), #6 Brief 1 (landing editorial), #8 Brief 2 (legacy CSS deletion + WARN→FAIL graduation). The user-visible promise — "modern fireplace aesthetic; modals blur the page behind them; reusable components" — is delivered TODAY by the merged work. Co-authored-by: Cursor <cursoragent@cursor.com>
288 lines
13 KiB
Markdown
288 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).
|