deckhearth/.convoys/liquid-glass-modal-and-surface-primitive.md

289 lines
13 KiB
Markdown
Raw Permalink Normal View History

feat(design-system): Liquid Glass redesign portfolio — foundation + primitives + Layout (#95) * feat(design-system): Liquid Glass redesign portfolio — foundation + primitive kit + Layout shell 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> * fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView The portfolio squash inadvertently overwrote the pre-existing lib/use-focus-trap.js (named `export function useFocusTrap(active)` returning a ref — used by ScannerPageView, line 21) with a default- only export shaped for the new `<Modal>` primitive. Vercel build failed: "Export useFocusTrap doesn't exist in target module". Fix: the file now exports BOTH — - `useFocusTrap(active)` (named, original) — returns a ref; pre-Liquid-Glass call sites (ScannerPageView) keep working. - `useFocusTrapContainer({ active, containerRef, ... })` (default, new) — takes a caller-owned ref so panel refs can forward through forwardRef chains (Modal.js consumes this shape). Both hooks are commented to document which to use when. Modal.js imports default already, so no change needed there. Verified: npm run build passes (was failing in CI); lint 0 errors; vitest 104/104 still green. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 21:12:33 -04:00
---
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 37 (Collections cluster)
- [ ] Brief 3 — migrate modals 810 (CardDetail cluster + Upload)
- [ ] Brief 4 — migrate modals 1113 (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).