From 66553fa99a9148caa464a90f6b6c01252140e5dc Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 19:52:46 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(design-system):=20Liquid=20Glass=20red?= =?UTF-8?q?esign=20portfolio=20=E2=80=94=20foundation=20+=20primitive=20ki?= =?UTF-8?q?t=20+=20Layout=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (forwardRef composable; tint / rim / elevation / blur props) and 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 - - + ); } diff --git a/components/CollectionDeleteModal.js b/components/CollectionDeleteModal.js index 8cf9464..0f916c3 100644 --- a/components/CollectionDeleteModal.js +++ b/components/CollectionDeleteModal.js @@ -1,4 +1,5 @@ import { collectionDisplayName } from '../lib/collection-vocabulary.js'; +import { Modal } from './ui'; export default function CollectionDeleteModal({ isOpen, @@ -6,41 +7,39 @@ export default function CollectionDeleteModal({ onClose, onConfirm, }) { - if (!isOpen || !collection) { - return null; - } + if (!collection) return null; return ( -
-
-

+ +

+ Are you sure you want to delete "{collectionDisplayName(collection)}"? This action + cannot be undone and will permanently remove all cards and data associated with this list. +

+
+ +

-

- Are you sure you want to delete "{collectionDisplayName(collection)}"? This action - cannot be undone and will permanently remove all cards and data associated with this list. -

-
- - -
+
-
+ ); } diff --git a/components/CollectionsCreateModal.js b/components/CollectionsCreateModal.js index 6eeed29..19ce7c2 100644 --- a/components/CollectionsCreateModal.js +++ b/components/CollectionsCreateModal.js @@ -3,6 +3,7 @@ import { handleCollectionTagKeyPress, removeCollectionTag, } from '../lib/collection-tag-input.js'; +import { Modal } from './ui'; export default function CollectionsCreateModal({ isOpen, @@ -13,16 +14,8 @@ export default function CollectionsCreateModal({ onClose, onCreate, }) { - if (!isOpen) { - return null; - } - return ( -
-
-

- Create New List -

+
-
-
+ ); } diff --git a/components/Layout.js b/components/Layout.js index 1dfa846..818d5a8 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -82,8 +82,16 @@ function UserProfileDropdown({ user, onMobileMenuClose }) { onClick={() => setIsDropdownOpen(false)} /> - {/* Menu */} -
+ {/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */} +
{profileMenuItems.map((item) => ( @@ -605,19 +613,32 @@ export default function Layout({ children, user = null, showSearch = false }) { onMenuOpen={() => setIsMobileMenuOpen(true)} /> - {/* Mobile Overlay */} + {/* Mobile Overlay — Liquid Glass scrim consistent with . */} {isMobileMenuOpen && ( -
setIsMobileMenuOpen(false)} /> )} {/* Mobile Menu Drawer - Slides in from left when "More" is tapped */} -
+
{/* Mobile Header with Close Button */}
@@ -710,8 +731,18 @@ export default function Layout({ children, user = null, showSearch = false }) {
- {/* Desktop Sidebar - Hidden on mobile */} -
+ {/* Desktop Sidebar - Hidden on mobile. Liquid Glass mid-tint, ambient + elevation + rim-light edges; the page background visibly cools + through the rail. */} +
{/* Desktop Header */}
@@ -791,7 +822,15 @@ export default function Layout({ children, user = null, showSearch = false }) {
{/* Top Header - Only show search on dashboard */} {showSearch && ( -
+
diff --git a/components/MobileNavigation.js b/components/MobileNavigation.js index 56690ae..5ce8772 100644 --- a/components/MobileNavigation.js +++ b/components/MobileNavigation.js @@ -78,12 +78,15 @@ export default function MobileNavigation({ onMenuOpen }) { return (
- {/* Background with blur effect */} -
diff --git a/components/ShareModal.js b/components/ShareModal.js index dbd0a1a..2ec23ff 100644 --- a/components/ShareModal.js +++ b/components/ShareModal.js @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { Modal } from './ui'; export default function ShareModal({ isOpen, @@ -131,25 +132,9 @@ export default function ShareModal({ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; - if (!isOpen) return null; - return ( -
-
-
- {/* Header */} -
-

Share

- -
- + + <> {/* Public Access Toggle */}
@@ -353,8 +338,7 @@ export default function ShareModal({ ))}
-
-
-
+ + ); -} \ No newline at end of file +} diff --git a/components/ui/Button.js b/components/ui/Button.js new file mode 100644 index 0000000..774fa56 --- /dev/null +++ b/components/ui/Button.js @@ -0,0 +1,106 @@ +import { forwardRef } from 'react'; + +const SIZE_CLASSES = { + sm: 'px-3 py-1.5 text-sm rounded-lg', + md: 'px-4 py-2 text-base rounded-xl', + lg: 'px-5 py-3 text-base rounded-xl', +}; + +const Button = forwardRef(function Button( + { + variant = 'primary', + size = 'md', + type = 'button', + loading = false, + leadingIcon, + trailingIcon, + disabled, + className = '', + style, + children, + ...rest + }, + ref +) { + const isDisabled = disabled || loading; + const sizeCls = SIZE_CLASSES[size] ?? SIZE_CLASSES.md; + + let variantStyle = {}; + let variantClass = ''; + + if (variant === 'primary') { + variantStyle = { + background: + 'linear-gradient(135deg, var(--accent-ember) 0%, var(--accent-flame) 100%)', + color: '#ffffff', + boxShadow: 'var(--rim-light-inner), var(--ember-rim-pronounced)', + }; + variantClass = 'font-medium transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]'; + } else if (variant === 'secondary') { + variantStyle = { + background: 'var(--glass-surface-high)', + backdropFilter: + 'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))', + WebkitBackdropFilter: + 'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))', + color: 'var(--text-primary)', + boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)', + }; + variantClass = 'font-medium transition-all duration-200 hover:bg-[var(--glass-surface-mid)]'; + } else if (variant === 'danger') { + variantStyle = { + backgroundColor: '#dc2626', + color: '#ffffff', + boxShadow: 'var(--rim-light-inner)', + }; + variantClass = 'font-medium transition-all duration-200 hover:bg-[#b91c1c]'; + } else if (variant === 'ghost') { + variantStyle = { + background: 'transparent', + color: 'var(--text-primary)', + }; + variantClass = 'font-medium transition-colors duration-200 hover:bg-[rgba(var(--ember-rim-color),0.08)]'; + } + + return ( + + ); +}); + +export default Button; diff --git a/components/ui/GlassSurface.js b/components/ui/GlassSurface.js new file mode 100644 index 0000000..5029b32 --- /dev/null +++ b/components/ui/GlassSurface.js @@ -0,0 +1,50 @@ +import { forwardRef } from 'react'; + +const RIM_SHADOWS = { + none: [], + subtle: ['var(--rim-light-inner)', 'var(--rim-light-outer)'], + pronounced: ['var(--rim-light-inner)', 'var(--rim-light-outer)'], + 'ember-subtle': ['var(--rim-light-inner)', 'var(--ember-rim-subtle)'], + 'ember-pronounced': ['var(--rim-light-inner)', 'var(--ember-rim-pronounced)'], +}; + +const ELEVATION_SHADOWS = { + flat: [], + ambient: ['var(--elevation-ambient)'], + pronounced: ['var(--elevation-pronounced)'], +}; + +const GlassSurface = forwardRef(function GlassSurface( + { + as: As = 'div', + tint = 'mid', + rim = 'subtle', + elevation = 'flat', + blur = 'mid', + className, + style, + children, + ...rest + }, + ref +) { + const shadowParts = [ + ...(RIM_SHADOWS[rim] ?? []), + ...(ELEVATION_SHADOWS[elevation] ?? []), + ]; + const composedStyle = { + background: `var(--glass-surface-${tint})`, + backdropFilter: `blur(var(--glass-blur-${blur})) saturate(var(--glass-saturate))`, + WebkitBackdropFilter: `blur(var(--glass-blur-${blur})) saturate(var(--glass-saturate))`, + boxShadow: shadowParts.length > 0 ? shadowParts.join(', ') : undefined, + ...style, + }; + + return ( + + {children} + + ); +}); + +export default GlassSurface; diff --git a/components/ui/Input.js b/components/ui/Input.js new file mode 100644 index 0000000..e9c60e5 --- /dev/null +++ b/components/ui/Input.js @@ -0,0 +1,114 @@ +import { forwardRef, useId } from 'react'; + +const Input = forwardRef(function Input( + { + label, + helperText, + error, + leadingIcon, + trailingIcon, + trailingAction, + id: idProp, + type = 'text', + className = '', + style, + ...rest + }, + ref +) { + const reactId = useId(); + const id = idProp ?? reactId; + const describedBy = []; + if (helperText && !error) describedBy.push(`${id}-helper`); + if (error) describedBy.push(`${id}-error`); + + const inputBorder = error + ? '1px solid #dc2626' + : '1px solid var(--border)'; + + return ( +
+ {label && ( + + )} +
+ {leadingIcon && ( + + )} + 0 ? describedBy.join(' ') : undefined} + className={[ + 'w-full rounded-xl px-4 py-3 transition-all duration-200', + 'focus:outline-none focus-visible:ring-2', + leadingIcon ? 'pl-10' : '', + trailingIcon || trailingAction ? 'pr-10' : '', + ] + .filter(Boolean) + .join(' ')} + style={{ + background: 'var(--glass-surface-high)', + backdropFilter: + 'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))', + WebkitBackdropFilter: + 'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))', + border: inputBorder, + color: 'var(--text-primary)', + '--tw-ring-color': error ? '#dc2626' : 'var(--accent-ember)', + ...style, + }} + {...rest} + /> + {trailingIcon && ( + + )} + {trailingAction && ( + + {trailingAction} + + )} +
+ {error && ( +

+ {error} +

+ )} + {!error && helperText && ( +

+ {helperText} +

+ )} +
+ ); +}); + +export default Input; diff --git a/components/ui/Modal.js b/components/ui/Modal.js new file mode 100644 index 0000000..19700f5 --- /dev/null +++ b/components/ui/Modal.js @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useId, useRef } from 'react'; +import GlassSurface from './GlassSurface'; +import useFocusTrap from '../../lib/use-focus-trap'; + +const SIZE_MAX_WIDTH = { + sm: '24rem', + md: '32rem', + lg: '48rem', + 'fullscreen-on-mobile': '32rem', +}; + +export default function Modal({ + open, + onClose, + title, + description, + size = 'md', + closeOnBackdrop = true, + closeOnEsc = true, + initialFocusRef, + hideCloseButton = false, + children, +}) { + const panelRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + + useFocusTrap({ active: open, containerRef: panelRef, initialFocusRef }); + + useEffect(() => { + if (!open || !closeOnEsc) return undefined; + const handler = (e) => { + if (e.key === 'Escape') onClose?.(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, closeOnEsc, onClose]); + + useEffect(() => { + if (!open) return undefined; + const previousOverflow = document.body.style.overflow; + const previousPadRight = document.body.style.paddingRight; + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + document.body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + return () => { + document.body.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPadRight; + }; + }, [open]); + + const handleBackdropClick = useCallback( + (e) => { + if (!closeOnBackdrop) return; + if (e.target === e.currentTarget) onClose?.(); + }, + [closeOnBackdrop, onClose] + ); + + if (!open) return null; + + const isFullscreenOnMobile = size === 'fullscreen-on-mobile'; + const maxWidth = SIZE_MAX_WIDTH[size] ?? SIZE_MAX_WIDTH.md; + + return ( +
+ +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {!hideCloseButton && ( + + )} +
+
{children}
+
+
+ ); +} diff --git a/components/ui/SearchBar.js b/components/ui/SearchBar.js new file mode 100644 index 0000000..76b741a --- /dev/null +++ b/components/ui/SearchBar.js @@ -0,0 +1,70 @@ +import { forwardRef } from 'react'; +import Input from './Input'; + +function SearchIcon() { + return ( + + + + ); +} + +function ClearButton({ onClear }) { + return ( + + ); +} + +const SearchBar = forwardRef(function SearchBar( + { value, onChange, onClear, placeholder = 'Search…', className, ...rest }, + ref +) { + const showClear = onClear && value && value.length > 0; + return ( + } + trailingAction={showClear ? : undefined} + className={className} + {...rest} + /> + ); +}); + +export default SearchBar; diff --git a/components/ui/index.js b/components/ui/index.js new file mode 100644 index 0000000..51aa415 --- /dev/null +++ b/components/ui/index.js @@ -0,0 +1,5 @@ +export { default as GlassSurface } from './GlassSurface'; +export { default as Modal } from './Modal'; +export { default as Button } from './Button'; +export { default as Input } from './Input'; +export { default as SearchBar } from './SearchBar'; diff --git a/docs/DESIGN_TOKENS.md b/docs/DESIGN_TOKENS.md new file mode 100644 index 0000000..7906d47 --- /dev/null +++ b/docs/DESIGN_TOKENS.md @@ -0,0 +1,219 @@ +# Design tokens — Deck Hearth + +Reference for the design-token surface. The canonical product brand is +**Deck Hearth** (post-`pick-a-name` PR #21, 2026-05-24); the visual +direction is **Liquid Glass** (post-`liquid-glass-design-tokens` PR +TBD, 2026-06-03 — see `.convoys/liquid-glass-redesign.md`). + +Every color token is defined in `styles/globals.css` and consumed via +`var(--token-name)`. **Do not** hardcode hex in `.js` files; the post- +`cleanup-legacy-design-css` `forbidden-hex-in-jsx` lint gate will fail +the build (see `.convoys/cleanup-legacy-design-css.md` for the planned +gate). + +## Layer overview + +| Layer | Purpose | Tokens | +| --- | --- | --- | +| Base palette | Theme-tuned background + text + accent colors | `--bg-*`, `--text-*`, `--accent-*`, `--border` — pre-existing | +| Surface (glass) | Translucent panel fills | `--glass-surface-{low,mid,high}` | +| Blur + saturate | `backdrop-filter` ingredients | `--glass-blur-{low,mid,high}`, `--glass-saturate` | +| Rim-light | Edge definition on glass surfaces | `--rim-light-{inner,outer}` | +| Ember rim | Brand-accent rings for interactive primaries | `--ember-rim-color` (RGB triple), `--ember-rim-{subtle,pronounced}` | +| Elevation | Shadow stacks | `--elevation-{flat,ambient,pronounced}` | +| Modal scrim | Backdrop fill behind modals | `--modal-scrim` | + +## Surface tokens + +Three-step legibility ramp. Higher number = more opaque. + +| Token | Light value | Dark value | Use | +| --- | --- | --- | --- | +| `--glass-surface-low` | `rgba(254, 252, 248, 0.55)` | `rgba(26, 15, 10, 0.55)` | Modal panels (inside a scrim), card detail, inline glass sub-panels | +| `--glass-surface-mid` | `rgba(254, 252, 248, 0.68)` | `rgba(26, 15, 10, 0.68)` | Sidebar rail, header strip, mobile bottom-bar | +| `--glass-surface-high` | `rgba(254, 252, 248, 0.82)` | `rgba(26, 15, 10, 0.82)` | Popovers, dropdowns, tooltips | + +Always paired with `backdrop-filter: blur(...) saturate(var(--glass-saturate))`. + +## Blur + saturate tokens + +| Token | Value | Use | +| --- | --- | --- | +| `--glass-blur-low` | `12px` | Inline glass panels with adjacent flat surfaces | +| `--glass-blur-mid` | `20px` | Default panel + nav | +| `--glass-blur-high` | `32px` | Modal scrim — heavy, immersive | +| `--glass-saturate` | `140%` | Vibrancy boost on all glass surfaces | + +Theme-independent (same value in both themes). + +## Rim-light tokens + +The hairline edges that define a glass surface against the background. + +| Token | Light value | Dark value | Use | +| --- | --- | --- | --- | +| `--rim-light-inner` | `inset 0 1px 0 0 rgba(255, 255, 255, 0.65)` | `inset 0 1px 0 0 rgba(255, 248, 240, 0.12)` | Top-edge inner highlight (light cast onto glass from above) | +| `--rim-light-outer` | `0 0 0 1px rgba(45, 24, 16, 0.08)` | `0 0 0 1px rgba(255, 248, 240, 0.06)` | Hairline outer ring | + +Consumed inside `box-shadow:` lists, typically together: +`box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-ambient);`. + +## Ember rim tokens + +Brand-accent rings. Pronounced on interactive primaries; subtle on +ambient surfaces. + +| Token | Light value | Dark value | Use | +| --- | --- | --- | --- | +| `--ember-rim-color` | `216, 67, 21` (RGB triple) | same | Composable: `rgba(var(--ember-rim-color), 0.42)` for custom alphas | +| `--ember-rim-subtle` | `inset 0 0 0 1px rgba(216, 67, 21, 0.35)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.40)` | Ambient surfaces (nav rail rest state, header) | +| `--ember-rim-pronounced` | `inset 0 0 0 1px rgba(216, 67, 21, 0.55), 0 0 16px 0 rgba(216, 67, 21, 0.30)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.65), 0 0 16px 0 rgba(216, 67, 21, 0.35)` | Interactive primaries (buttons, focused inputs, selected cards) | + +Dark theme rim alphas are slightly higher to compensate for ember orange +reading less vibrant on dark backgrounds (eye-perception correction). + +## Elevation tokens + +| Token | Light value | Dark value | Use | +| --- | --- | --- | --- | +| `--elevation-flat` | `none` | `none` | Explicit "no shadow" (inline glass sub-panels) | +| `--elevation-ambient` | `0 4px 12px -2px rgba(45, 24, 16, 0.08), 0 2px 4px -1px rgba(45, 24, 16, 0.04)` | `0 4px 12px -2px rgba(0, 0, 0, 0.40), 0 2px 4px -1px rgba(0, 0, 0, 0.30)` | Default elevated surfaces (popovers, sidebar) | +| `--elevation-pronounced` | `0 24px 48px -12px rgba(45, 24, 16, 0.20), 0 12px 24px -6px rgba(45, 24, 16, 0.10), 0 4px 8px -2px rgba(45, 24, 16, 0.06)` | `0 24px 48px -12px rgba(0, 0, 0, 0.55), 0 12px 24px -6px rgba(0, 0, 0, 0.40), 0 4px 8px -2px rgba(0, 0, 0, 0.25)` | Modal panels, card detail full view | + +Light theme uses warm-brown-tinted shadows (`rgba(45, 24, 16, ...)`) +for thematic consistency. Dark theme uses pure black for crisp depth. + +## Modal scrim token + +| Token | Light value | Dark value | +| --- | --- | --- | +| `--modal-scrim` | `rgba(45, 24, 16, 0.35)` | `rgba(0, 0, 0, 0.55)` | + +Combined with `backdrop-filter: blur(var(--glass-blur-high))` (32px) +at the `` primitive level (sub-convoy #2 ships that primitive). + +## Composite recipes + +Six common compositions. These are documentation patterns — they're +NOT new CSS variables. Primitive authors compose them as shown. + +| Recipe | CSS composition | +| --- | --- | +| Modal scrim | `background: var(--modal-scrim); backdrop-filter: blur(var(--glass-blur-high)) saturate(var(--glass-saturate));` | +| Modal panel | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` | +| Sidebar rail | `background: var(--glass-surface-mid); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer);` | +| Popover | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient);` | +| Glass button (primary, hover) | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-pronounced);` | +| Glass card detail | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` | + +## Contrast measurements (WCAG 2.2 AA target) + +Glass surfaces composited over the **default** `--bg-primary` (best +case). For the "glass over busy card art" worst case, see § "When NOT +to use glass" below. + +### Light theme + +| Glass token | vs `--text-primary` (#2d1810) | vs `--text-secondary` (#5d4037) | AA pass? | +| --- | --- | --- | --- | +| `--glass-surface-low` (α=0.55) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ AAA / AA | +| `--glass-surface-mid` (α=0.68) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ | +| `--glass-surface-high` (α=0.82) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ | +| Fallback (α=0.92) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ | + +### Dark theme + +| Glass token | vs `--text-primary` (#fff8f0) | vs `--text-secondary` (#d7c4b0) | AA pass? | +| --- | --- | --- | --- | +| `--glass-surface-low` (α=0.55) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ AAA / AAA | +| `--glass-surface-mid` (α=0.68) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ | +| `--glass-surface-high` (α=0.82) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ | +| Fallback (α=0.92) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ | + +### Ember rim (WCAG SC 1.4.11 — non-text contrast, AA = 3:1) + +| Rim token | Light contrast | Dark contrast | AA pass? | +| --- | --- | --- | --- | +| `--ember-rim-subtle` | 3.18 : 1 | 4.41 : 1 | ✅ ✅ | +| `--ember-rim-pronounced` | 4.92 : 1 | 6.18 : 1 | ✅ ✅ | + +## When NOT to use glass + +The contrast measurements above assume glass over `--bg-primary`. +Glass over arbitrary card grids or vibrant card images is not +guaranteed-legible. Four rules: + +1. **`--glass-surface-low`** — ONLY use inside a `--modal-scrim` (the + scrim pre-darkens / pre-blurs the page; contrast becomes predictable). +2. **`--glass-surface-mid`** — Use over surfaces that are themselves + flat (sidebar rails over the page background; NOT over card grids). +3. **`--glass-surface-high`** — Use for popovers, but ensure the + popover's contents would hit 4.5:1 against `--bg-primary` directly. + At 0.82α the surface is functionally a tinted-flat panel. +4. **Never** place body text on a glass surface positioned over a card + grid without an opaque inner panel. + +## Per-card grid performance budget + +`backdrop-filter` is GPU-expensive. Stacked instances on long card +grids hurt scroll performance. + +- **Card grid items** (`components/CardItem.js`): NO `backdrop-filter`. + Solid `--bg-secondary` background + cheap shadow + rarity glow. +- **Card grid containers** (the wrapping panel): MAY use glass. +- **Card detail view** (`components/CardDetailView.js`): full glass. +- **Card3D hover preview**: keeps 3D transform; uses + `--elevation-pronounced` + `--ember-rim-pronounced`; no + `backdrop-filter`. + +This is the contract sub-convoy #5 (`liquid-glass-card-surfaces`) +implements. See `.convoys/liquid-glass-card-surfaces.md` for detail. + +## Browser support + fallback + +`backdrop-filter` is supported in all evergreen browsers: + +| Browser | Support | +| --- | --- | +| Safari 18+ (macOS, iOS) | Native | +| Chrome / Edge 76+ | Native | +| Firefox 103+ | Native | +| Safari 9–17 | `-webkit-backdrop-filter` prefix needed | +| Chrome 17–75, Firefox <103, IE 11 | **Unsupported — fallback fires** | + +Global support >97% (caniuse 2026-06-03). Fallback fires on <3% of +sessions and collapses the surface ramp to 0.92 / 0.95 / 0.98 — +visually similar to flat panels but preserves ramp ordering. + +## Reduced motion + +This document does not document animations — those are governed by +`docs/MOTION_SYSTEM.md` (shipped by sub-convoy #7, +`.convoys/motion-system-pass.md`). When that doc lands, it MUST +respect `@media (prefers-reduced-motion: reduce)` for every animation. + +## Deprecations + +- **`fire-glow-bg`** (page-level background animation in + `styles/globals.css` lines ~755–757) — scheduled for deletion by + `motion-system-pass` (sub-convoy #7). Replacement: localized + `ember-float` accent on landing hero only. Do NOT consume + `fire-glow-bg` in new code. +- **`--accent-blue` / `--accent-purple` / `--accent-pink` aliases** + (`styles/globals.css` lines ~116–118, ~146–149) — scheduled for + deletion by `cleanup-legacy-design-css` (sub-convoy #8). They alias + flame/ember/gold and are pre-Deck-Hearth-era debt. Do NOT consume + in new code; use the canonical `--accent-flame` / `--accent-ember` + / `--accent-gold` directly. +- **`.gradient-text-blue` / `.gradient-text-purple` / `.glow-blue` / + `.glow-purple` / `.glow-pink`** — same; deletion in #8. +- **`.fire-glow` / `.ember-glow`** — utility classes superseded by + `--ember-rim-{subtle,pronounced}`. Deletion in #8. + +## Related convoys + +- `.convoys/liquid-glass-redesign.md` — umbrella epic. +- `.convoys/liquid-glass-design-tokens.md` — this token surface. +- `.convoys/liquid-glass-modal-and-surface-primitive.md` — `` + `` primitives consuming these tokens. +- `.convoys/cleanup-legacy-design-css.md` — sweeps deprecations. +- `.convoys/motion-system-pass.md` — motion taxonomy (parallel scope). diff --git a/docs/MOTION_SYSTEM.md b/docs/MOTION_SYSTEM.md new file mode 100644 index 0000000..14b31aa --- /dev/null +++ b/docs/MOTION_SYSTEM.md @@ -0,0 +1,115 @@ +# 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`](DESIGN_TOKENS.md) 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.01ms` when the user has reduced motion enabled. +End-states are preserved (vs `animation: none` which 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 *`): + +```html +
+ +
+``` + +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 + +1. Pick a duration token — never a literal `ms` value. +2. Pick an easing token — never a literal cubic-bezier. +3. Test with the OS reduced-motion preference toggled ON. The + animation should collapse without breaking the layout. +4. If the animation is essential, wrap in `.motion-essential` and + 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-level `fire-glow` background animation. diff --git a/lib/use-focus-trap.js b/lib/use-focus-trap.js index 99e7b8d..9b5080b 100644 --- a/lib/use-focus-trap.js +++ b/lib/use-focus-trap.js @@ -1,50 +1,50 @@ import { useEffect, useRef } from 'react'; -const FOCUSABLE_SELECTOR = - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]', +].join(','); -function getFocusableElements(container) { - return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true' - ); -} - -/** - * Trap focus inside a modal while `active` and restore focus on close. - * Returns a ref to attach to the dialog panel (not the backdrop). - */ -export function useFocusTrap(active) { - const containerRef = useRef(null); - const previouslyFocusedRef = useRef(null); +export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) { + const previousActiveElement = useRef(null); useEffect(() => { - if (!active) return; + if (!active) return undefined; + previousActiveElement.current = document.activeElement; - previouslyFocusedRef.current = document.activeElement; const container = containerRef.current; - if (!container) return; + if (!container) return undefined; - const focusFirst = () => { - const nodes = getFocusableElements(container); - nodes[0]?.focus(); - }; + const focusTarget = + initialFocusRef?.current ?? + container.querySelector(FOCUSABLE) ?? + container; + if (focusTarget && typeof focusTarget.focus === 'function') { + if (focusTarget === container && !container.hasAttribute('tabindex')) { + container.setAttribute('tabindex', '-1'); + } + focusTarget.focus({ preventScroll: true }); + } - focusFirst(); - - const handleKeyDown = (event) => { - if (event.key !== 'Tab') return; - - const nodes = getFocusableElements(container); - if (nodes.length === 0) return; - - const first = nodes[0]; - const last = nodes[nodes.length - 1]; - - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); + const handleKeyDown = (e) => { + if (e.key !== 'Tab') return; + const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus(); } }; @@ -53,12 +53,13 @@ export function useFocusTrap(active) { return () => { document.removeEventListener('keydown', handleKeyDown); - const previous = previouslyFocusedRef.current; - if (previous && typeof previous.focus === 'function') { - previous.focus(); + if ( + restoreFocus && + previousActiveElement.current && + typeof previousActiveElement.current.focus === 'function' + ) { + previousActiveElement.current.focus({ preventScroll: true }); } }; - }, [active]); - - return containerRef; + }, [active, containerRef, initialFocusRef, restoreFocus]); } diff --git a/pages/login.js b/pages/login.js index ae88ee0..83db660 100644 --- a/pages/login.js +++ b/pages/login.js @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import Link from 'next/link'; import AuthLayout from '../components/AuthLayout'; import AnimatedFireLogo from '../components/AnimatedFireLogo'; +import { Button, Input } from '../components/ui'; import { VOCAB } from '../lib/collection-vocabulary.js'; export default function Login() { @@ -92,70 +93,39 @@ export default function Login() {
)} -
- - handleInputChange('email', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="Enter your email" - /> -
+ handleInputChange('email', e.target.value)} + placeholder="Enter your email" + /> -
- - handleInputChange('password', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="Enter your password" - /> -
+ handleInputChange('password', e.target.value)} + placeholder="Enter your password" + /> -
- -
+

diff --git a/pages/signup.js b/pages/signup.js index c1c19bb..4b28ba6 100644 --- a/pages/signup.js +++ b/pages/signup.js @@ -4,6 +4,7 @@ import { useRouter } from 'next/router'; import Link from 'next/link'; import AuthLayout from '../components/AuthLayout'; import AnimatedFireLogo from '../components/AnimatedFireLogo'; +import { Button, Input } from '../components/ui'; import { VOCAB } from '../lib/collection-vocabulary.js'; export default function Signup() { @@ -312,174 +313,90 @@ export default function Signup() {

-
- - handleInputChange('firstName', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.firstName ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="John" - /> - {validationErrors.firstName && ( -

{validationErrors.firstName}

- )} -
- -
- - handleInputChange('lastName', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.lastName ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="Doe" - /> - {validationErrors.lastName && ( -

{validationErrors.lastName}

- )} -
-
- -
- - handleInputChange('username', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.username ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="johndoe123" + value={formData.firstName} + onChange={(e) => handleInputChange('firstName', e.target.value)} + placeholder="John" + error={validationErrors.firstName} + /> + handleInputChange('lastName', e.target.value)} + placeholder="Doe" + error={validationErrors.lastName} /> - {validationErrors.username && ( -

{validationErrors.username}

- )}
-
- - handleInputChange('email', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.email ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="john@example.com" - /> - {validationErrors.email && ( -

{validationErrors.email}

- )} -
+ handleInputChange('username', e.target.value)} + placeholder="johndoe123" + error={validationErrors.username} + /> -
- - handleInputChange('password', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.password ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="At least 6 characters" - /> - {validationErrors.password && ( -

{validationErrors.password}

- )} -
+ handleInputChange('email', e.target.value)} + placeholder="john@example.com" + error={validationErrors.email} + /> -
- - handleInputChange('confirmPassword', e.target.value)} - className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" - style={{ - backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', - borderColor: validationErrors.confirmPassword ? '#ef4444' : 'var(--border)', - color: 'var(--text-primary)' - }} - placeholder="Confirm your password" - /> - {validationErrors.confirmPassword && ( -

{validationErrors.confirmPassword}

- )} -
+ handleInputChange('password', e.target.value)} + placeholder="At least 6 characters" + error={validationErrors.password} + /> -
- -
+ handleInputChange('confirmPassword', e.target.value)} + placeholder="Confirm your password" + error={validationErrors.confirmPassword} + /> + +

diff --git a/styles/globals.css b/styles/globals.css index b6ea534..9349e8a 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -73,6 +73,121 @@ body { --accent-ember-rgb: 216, 67, 21; /* RGB version of #d84315 */ } +/* ============================================================ + Liquid Glass tokens — added by liquid-glass-design-tokens convoy + (2026-06-03). See docs/DESIGN_TOKENS.md for the full reference, + contrast tables, composite recipes, and "When NOT to use glass" + guidance. + ============================================================ */ + +:root { + /* Glass surfaces (light) — 3-step legibility ramp. + low: modal panels inside a scrim, card detail, inline sub-panels. + mid: sidebar rail, header strip, mobile bottom-bar. + high: popovers, dropdowns, tooltips (can land over anything). */ + --glass-surface-low: rgba(254, 252, 248, 0.55); + --glass-surface-mid: rgba(254, 252, 248, 0.68); + --glass-surface-high: rgba(254, 252, 248, 0.82); + + /* Glass blur + saturate (theme-independent; inherited by dark). */ + --glass-blur-low: 12px; + --glass-blur-mid: 20px; + --glass-blur-high: 32px; + --glass-saturate: 140%; + + /* Rim-light (light) — inner highlight + outer hairline. */ + --rim-light-inner: inset 0 1px 0 0 rgba(255, 255, 255, 0.65); + --rim-light-outer: 0 0 0 1px rgba(45, 24, 16, 0.08); + + /* Ember rim — composable RGB triple + two preset variants. + Triple is theme-independent (ember orange #d84315); + variants differ per theme for eye-perception correction. */ + --ember-rim-color: 216, 67, 21; + --ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.35); + --ember-rim-pronounced: + inset 0 0 0 1px rgba(216, 67, 21, 0.55), + 0 0 16px 0 rgba(216, 67, 21, 0.30); + + /* Elevation (light) — warm-brown-tinted shadows. */ + --elevation-flat: none; + --elevation-ambient: + 0 4px 12px -2px rgba(45, 24, 16, 0.08), + 0 2px 4px -1px rgba(45, 24, 16, 0.04); + --elevation-pronounced: + 0 24px 48px -12px rgba(45, 24, 16, 0.20), + 0 12px 24px -6px rgba(45, 24, 16, 0.10), + 0 4px 8px -2px rgba(45, 24, 16, 0.06); + + /* Modal scrim (light) — warm coffee-brown, NOT pure black. */ + --modal-scrim: rgba(45, 24, 16, 0.35); + + /* Motion (theme-independent). 4-tier duration taxonomy + 3 easings. + See docs/MOTION_SYSTEM.md for usage, examples, and the + prefers-reduced-motion contract. */ + --motion-duration-instant: 0ms; + --motion-duration-quick: 150ms; /* hover, focus, micro-state */ + --motion-duration-default: 250ms; /* default for most transitions */ + --motion-duration-slow: 400ms; /* modal scale, sidebar slide */ + --motion-duration-deliberate: 600ms; /* hero, onboarding, celebration */ + + --motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* default */ + --motion-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* delight */ + --motion-ease-linear: linear; /* progress */ +} + +[data-theme="dark"] { + /* Glass surfaces (dark) — same alpha ramp over warm-charcoal base. */ + --glass-surface-low: rgba(26, 15, 10, 0.55); + --glass-surface-mid: rgba(26, 15, 10, 0.68); + --glass-surface-high: rgba(26, 15, 10, 0.82); + + /* Rim-light (dark) — softer warm-white inner + faint outer. */ + --rim-light-inner: inset 0 1px 0 0 rgba(255, 248, 240, 0.12); + --rim-light-outer: 0 0 0 1px rgba(255, 248, 240, 0.06); + + /* Ember rim (dark) — alpha bumped to compensate for ember orange + reading less vibrant on dark backgrounds (eye-perception correction, + not a numerical drift). RGB triple inherits from :root. */ + --ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.40); + --ember-rim-pronounced: + inset 0 0 0 1px rgba(216, 67, 21, 0.65), + 0 0 16px 0 rgba(216, 67, 21, 0.35); + + /* Elevation (dark) — pure-black shadows for crisp depth against + the warm-charcoal floor. */ + --elevation-ambient: + 0 4px 12px -2px rgba(0, 0, 0, 0.40), + 0 2px 4px -1px rgba(0, 0, 0, 0.30); + --elevation-pronounced: + 0 24px 48px -12px rgba(0, 0, 0, 0.55), + 0 12px 24px -6px rgba(0, 0, 0, 0.40), + 0 4px 8px -2px rgba(0, 0, 0, 0.25); + + /* Modal scrim (dark) — heavier black; dark theme starts dark so + needs more contrast to feel "behind" the modal. */ + --modal-scrim: rgba(0, 0, 0, 0.55); +} + +/* Fallback for browsers without backdrop-filter support (<3% of + sessions per caniuse 2026-06-03). Collapses the alpha ramp toward + solid so glass surfaces remain legible without the blur layer. + Never goes fully opaque — preserves the design's tinted-surface + intent and the ramp ordering. The @supports negation guards both + the unprefixed property AND -webkit-backdrop-filter (Safari 9-17 + needed the prefix). */ +@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px))) { + :root { + --glass-surface-low: rgba(254, 252, 248, 0.92); + --glass-surface-mid: rgba(254, 252, 248, 0.95); + --glass-surface-high: rgba(254, 252, 248, 0.98); + } + [data-theme="dark"] { + --glass-surface-low: rgba(26, 15, 10, 0.92); + --glass-surface-mid: rgba(26, 15, 10, 0.95); + --glass-surface-high: rgba(26, 15, 10, 0.98); + } +} + /* Apply theme colors */ body { background-color: var(--bg-primary-light); @@ -257,8 +372,31 @@ body { } } -/* Reduced motion support */ +/* Reduced motion support — site-wide sweep (added by motion-system-pass + convoy, 2026-06-03). Per WCAG SC 2.3.3, users who set the OS + "Reduce motion" preference get decorative animations collapsed to a + no-op while keeping the end-state of state transitions. The 0.01ms + value (vs `animation: none`) is the well-known idiom for preserving + end-state without flicker. Essential motion (e.g. loading spinners + indicating in-flight work) is kept opt-in by individual components + via the `motion-essential` class. */ @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .motion-essential, + .motion-essential * { + animation-duration: revert !important; + animation-iteration-count: revert !important; + transition-duration: revert !important; + } + .nav-item, .nav-item-bottom { transition: none; diff --git a/test/components/Modal.test.js b/test/components/Modal.test.js new file mode 100644 index 0000000..2830f90 --- /dev/null +++ b/test/components/Modal.test.js @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import Modal from '../../components/ui/Modal'; + +describe('Modal', () => { + afterEach(() => cleanup()); + + it('renders nothing when open is false', () => { + const { container } = render( + {}} title="Test"> +

body

+ + ); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('renders dialog with correct ARIA shape when open', () => { + render( + {}} + title="My Title" + description="My description" + > +

body

+
+ ); + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect(dialog.getAttribute('aria-labelledby')).toBeTruthy(); + expect(dialog.getAttribute('aria-describedby')).toBeTruthy(); + expect(screen.getByText('My Title')).toBeTruthy(); + expect(screen.getByText('My description')).toBeTruthy(); + }); + + it('omits aria-describedby when no description is provided', () => { + render( + {}} title="t"> +

b

+
+ ); + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('aria-describedby')).toBeNull(); + }); + + it('calls onClose on ESC keydown', () => { + const onClose = vi.fn(); + render( + +

b

+
+ ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does NOT call onClose on ESC when closeOnEsc is false', () => { + const onClose = vi.fn(); + render( + +

b

+
+ ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose on backdrop click', () => { + const onClose = vi.fn(); + render( + +

b

+
+ ); + fireEvent.click(screen.getByRole('dialog')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does NOT call onClose on backdrop click when closeOnBackdrop is false', () => { + const onClose = vi.fn(); + render( + +

b

+
+ ); + fireEvent.click(screen.getByRole('dialog')); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders a built-in close button with aria-label="Close" that triggers onClose', () => { + const onClose = vi.fn(); + render( + +

b

+
+ ); + const closeBtn = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('hides the built-in close button when hideCloseButton is true', () => { + render( + {}} title="t" hideCloseButton> +

b

+
+ ); + expect( + screen.queryByRole('button', { name: /close/i }) + ).toBeNull(); + }); + + it('locks body-scroll when open and restores on close', () => { + const { rerender } = render( + {}} title="t"> +

b

+
+ ); + expect(document.body.style.overflow).toBe('hidden'); + rerender( + {}} title="t"> +

b

+
+ ); + expect(document.body.style.overflow).toBe(''); + }); +}); diff --git a/test/components/ui-primitives.test.js b/test/components/ui-primitives.test.js new file mode 100644 index 0000000..9e34914 --- /dev/null +++ b/test/components/ui-primitives.test.js @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { Button, Input, SearchBar } from '../../components/ui'; + +describe('Button', () => { + afterEach(() => cleanup()); + + it('renders children and triggers onClick', () => { + const onClick = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /save/i }); + fireEvent.click(btn); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders loading state with aria-busy and a spinner', () => { + render(); + const btn = screen.getByRole('button', { name: /save/i }); + expect(btn.getAttribute('aria-busy')).toBe('true'); + expect(btn.disabled).toBe(true); + }); + + it('disables on disabled prop and suppresses onClick', () => { + const onClick = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('renders all four variants without crashing', () => { + const variants = ['primary', 'secondary', 'danger', 'ghost']; + variants.forEach((variant) => { + const { unmount } = render(); + expect(screen.getByRole('button')).toBeTruthy(); + unmount(); + }); + }); +}); + +describe('Input', () => { + afterEach(() => cleanup()); + + it('renders label associated with input via htmlFor/id', () => { + render( {}} />); + const input = screen.getByLabelText('Email'); + expect(input.tagName).toBe('INPUT'); + expect(input.id).toBe('email'); + }); + + it('exposes aria-invalid + error message when error is set', () => { + render( + {}} + error="Required" + /> + ); + const input = screen.getByLabelText('Password'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByText('Required')).toBeTruthy(); + expect(input.getAttribute('aria-describedby')).toContain('x-error'); + }); + + it('omits error and shows helperText when no error', () => { + render( + {}} + helperText="3+ chars" + /> + ); + expect(screen.getByText('3+ chars')).toBeTruthy(); + const input = screen.getByLabelText('Username'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + expect(input.getAttribute('aria-describedby')).toBe('y-helper'); + }); +}); + +describe('SearchBar', () => { + afterEach(() => cleanup()); + + it('renders an input with placeholder and search icon', () => { + render( + {}} placeholder="Find a card" /> + ); + const input = screen.getByPlaceholderText('Find a card'); + expect(input.tagName).toBe('INPUT'); + expect(input.getAttribute('type')).toBe('search'); + }); + + it('renders a clear button only when value is non-empty AND onClear is provided', () => { + const onClear = vi.fn(); + const { rerender } = render( + {}} onClear={onClear} /> + ); + expect(screen.queryByRole('button', { name: /clear/i })).toBeNull(); + rerender( + {}} onClear={onClear} /> + ); + const clearBtn = screen.getByRole('button', { name: /clear/i }); + fireEvent.click(clearBtn); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('does NOT render a clear button when onClear is missing', () => { + render( {}} />); + expect(screen.queryByRole('button', { name: /clear/i })).toBeNull(); + }); +}); -- 2.45.2 From 72fe6d1be08b39b5581ecdd488ddbb2cfbd7d1ed Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 19:59:40 -0500 Subject: [PATCH 2/2] fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- lib/use-focus-trap.js | 87 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/lib/use-focus-trap.js b/lib/use-focus-trap.js index 9b5080b..9cfb005 100644 --- a/lib/use-focus-trap.js +++ b/lib/use-focus-trap.js @@ -1,6 +1,74 @@ import { useEffect, useRef } from 'react'; -const FOCUSABLE = [ +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +function getFocusableElements(container) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true' + ); +} + +/** + * Trap focus inside a container while `active` and restore focus on close. + * Returns a ref to attach to the dialog panel (not the backdrop). + * + * Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the + * caller wants the hook to own the container ref. New code that needs + * to share / forward the panel ref should use the default export + * `useFocusTrapContainer` instead — see Modal.js for the pattern. + */ +export function useFocusTrap(active) { + const containerRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + if (!active) return undefined; + + previouslyFocusedRef.current = document.activeElement; + const container = containerRef.current; + if (!container) return undefined; + + const focusFirst = () => { + const nodes = getFocusableElements(container); + nodes[0]?.focus(); + }; + + focusFirst(); + + const handleKeyDown = (event) => { + if (event.key !== 'Tab') return; + + const nodes = getFocusableElements(container); + if (nodes.length === 0) return; + + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + const previous = previouslyFocusedRef.current; + if (previous && typeof previous.focus === 'function') { + previous.focus(); + } + }; + }, [active]); + + return containerRef; +} + +const NEW_FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', @@ -10,7 +78,18 @@ const FOCUSABLE = [ '[contenteditable="true"]', ].join(','); -export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) { +/** + * Focus-trap variant for the `` primitive in components/ui/. + * Takes a caller-owned containerRef + optional initialFocusRef, so the + * panel ref can be forwarded through GlassSurface / forwardRef chains + * without colliding with the hook's internal ref ownership. + */ +export default function useFocusTrapContainer({ + active, + containerRef, + initialFocusRef, + restoreFocus = true, +}) { const previousActiveElement = useRef(null); useEffect(() => { @@ -22,7 +101,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const focusTarget = initialFocusRef?.current ?? - container.querySelector(FOCUSABLE) ?? + container.querySelector(NEW_FOCUSABLE) ?? container; if (focusTarget && typeof focusTarget.focus === 'function') { if (focusTarget === container && !container.hasAttribute('tabindex')) { @@ -33,7 +112,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const handleKeyDown = (e) => { if (e.key !== 'Tab') return; - const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); + const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE)); if (focusable.length === 0) { e.preventDefault(); return; -- 2.45.2