* 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>
5.7 KiB
Motion system — Deck Hearth
Reference for the motion-token surface and the prefers-reduced-motion
contract. Motion tokens are defined in styles/globals.css alongside
the design-tokens and consumed as
var(--motion-duration-…) / var(--motion-ease-…).
Why bother
Motion shapes how an app feels more than any other surface. Loose animations (random durations, jittery easings, no reduced-motion respect) read as amateur. The 4-tier taxonomy here exists so every new animation lands in one of four buckets — never an arbitrary duration — and every animation is reduced-motion safe by default.
Duration taxonomy
Four discrete durations + an instant escape hatch. Pick the bucket that matches the meaning, not the visual feel.
| Token | Value | Use | Examples |
|---|---|---|---|
--motion-duration-instant |
0ms |
Theme/route transitions where any duration is wrong; hover state for keyboard-only users | Color flips on data-theme change |
--motion-duration-quick |
150ms |
Hover, focus, micro-feedback. Snappy enough that users don't perceive it as animation | Button hover scale, focus-ring fade-in |
--motion-duration-default |
250ms |
Default for state transitions. The token most CSS transition: blocks should reach for |
Card hover, link color, glass-surface opacity |
--motion-duration-slow |
400ms |
Entering/exiting layout surfaces. Slow enough to read; fast enough not to feel sluggish | Modal scale/opacity, sidebar drawer slide, popover open |
--motion-duration-deliberate |
600ms |
Hero moments, celebration, onboarding. Use sparingly — feels heavy if overused | Empty-state illustrations, scanner success confetti |
Rule of thumb: if you can't justify why a duration is not default,
use default.
Easing taxonomy
Three curves. Two ease-outs (one calm, one playful) plus linear for progress indicators.
| Token | Curve | Use |
|---|---|---|
--motion-ease-out |
cubic-bezier(0.16, 1, 0.3, 1) |
Default for entrances + state changes. Decelerates softly — reads as "settling." |
--motion-ease-spring |
cubic-bezier(0.34, 1.56, 0.64, 1) |
Slight overshoot for delight (button press, modal open). Use only when the motion is the point — sparingly. |
--motion-ease-linear |
linear |
Progress indicators (spinners, loading bars). Any non-linear curve here implies state change, which is wrong for a progress affordance. |
Composition recipes
| Recipe | CSS |
|---|---|
| Default hover | transition: all var(--motion-duration-quick) var(--motion-ease-out); |
| Default state change | transition: var(--motion-duration-default) var(--motion-ease-out); |
| Modal enter | transition: opacity var(--motion-duration-slow) var(--motion-ease-out), transform var(--motion-duration-slow) var(--motion-ease-spring); |
| Sidebar slide | transition: transform var(--motion-duration-slow) var(--motion-ease-out); |
| Loading spinner | animation: spin var(--motion-duration-deliberate) var(--motion-ease-linear) infinite; |
The prefers-reduced-motion contract
WCAG 2.2 SC 2.3.3 Level AAA: provide a mechanism for users to disable
non-essential motion. Operating systems already expose this preference;
@media (prefers-reduced-motion: reduce) reads it and we honor it.
The site-wide rule in styles/globals.css collapses every animation
- transition to
0.01mswhen the user has reduced motion enabled. End-states are preserved (vsanimation: nonewhich can flicker).
When motion is essential
Some motion is essential to communicate state — a loading spinner
indicating in-flight work, an actively-scanning camera reticle. For
those, add the .motion-essential class to the animating element
(or to a parent — children inherit via .motion-essential *):
<div class="motion-essential">
<svg class="animate-spin">…</svg>
</div>
Use this only when stopping the animation would hide meaningful state. A purely decorative bounce or shimmer is NOT essential — leave it to collapse with reduced motion.
Current motion-essential consumers
None yet — when sub-convoy #5 (liquid-glass-card-surfaces) lands the
scanner reticle as a glass-aware motion, it will be the first
documented consumer.
Audit of existing animations
These keyframes pre-date the motion taxonomy and continue to play
under the per-class rules in styles/globals.css:
| Animation | Duration (legacy) | Status |
|---|---|---|
mythic-sparkle |
4s infinite | Decorative, collapses under reduced motion. Reconciled with glass in #5. |
rare-shimmer |
3s infinite | Decorative, collapses under reduced motion. |
uncommon-twinkle |
2.5s infinite | Decorative, collapses under reduced motion. |
enchanted-rainbow |
3s infinite | Decorative, collapses under reduced motion. |
float (logo) |
4s infinite | Decorative, collapses under reduced motion. |
fire-glow (page background) |
12s infinite | Scheduled for deletion by cleanup-legacy-design-css (#8). Replaced by localized ember-float on landing hero only. |
ember-float |
10s linear infinite | Kept for landing hero accent. Collapses under reduced motion. |
Adding a new animation
- Pick a duration token — never a literal
msvalue. - Pick an easing token — never a literal cubic-bezier.
- Test with the OS reduced-motion preference toggled ON. The animation should collapse without breaking the layout.
- If the animation is essential, wrap in
.motion-essentialand document the why in a code comment.
Related convoys
.convoys/motion-system-pass.md— this convoy..convoys/liquid-glass-design-tokens.md— token surface motion lives in..convoys/cleanup-legacy-design-css.md— deletes the legacy page-levelfire-glowbackground animation.