diff --git a/.convoys/cleanup-legacy-design-css.md b/.convoys/cleanup-legacy-design-css.md new file mode 100644 index 0000000..0b29f47 --- /dev/null +++ b/.convoys/cleanup-legacy-design-css.md @@ -0,0 +1,294 @@ +--- +name: cleanup-legacy-design-css +classification: feature +success_metric: | + The legacy gradient-text / glow / accent-blue/purple/pink utility + surface is deleted from `styles/globals.css`; no consumer remains + (verified by `rg`); hardcoded hex sweep across `components/**` + + `pages/**` complete; `.cursor/rules/ui-and-theming.mdc` is + updated to make Liquid Glass tokens + primitives the canonical + pattern; `forbidden-legacy-design-css` lint / grep gates are wired + to prevent regression; lint + vitest + smoke green. +skip: + - ia + - ux +status: ci-gates-shipped-deletion-queued +created: 2026-06-03 +ci_gates_shipped: 2026-06-03 +depends_on: + - liquid-glass-design-tokens + - liquid-glass-modal-and-surface-primitive + - liquid-glass-form-primitives + - liquid-glass-layout-shell + - liquid-glass-card-surfaces + - liquid-glass-public-and-auth + - motion-system-pass +umbrella: liquid-glass-redesign +--- + +# Convoy: cleanup-legacy-design-css + +Sub-convoy #8 of the `liquid-glass-redesign` epic. Strict-deletion +convoy — ships last, after every other sub-convoy has migrated off the +legacy surface. No new styling. No new components. Only deletions and +CI gates to prevent re-introduction. + +## Why + +Without an enforced cleanup at the end, the legacy utility classes +(`gradient-text-blue`, `glow-blue`, `accent-purple`, etc. — every one +inherited from a pre-Liquid-Glass era) will quietly reappear in future +PRs as developers' muscle memory pastes the old patterns. The way to +prevent that is: + +1. Delete the legacy surface from `styles/globals.css`. +2. Sweep any remaining hex colors that ought to be tokens. +3. Update `.cursor/rules/ui-and-theming.mdc` to make the Liquid Glass + primitives the canonical pattern. +4. Wire `forbidden-*` grep gates in CI so the legacy patterns can't + land again. + +This convoy is **predicated on every other sub-convoy having shipped +first**. If any sub-convoy is still in flight, this convoy waits. + +## Scope + +### In scope — deletions from `styles/globals.css` + +- Legacy gradient-text utility classes: + - `.gradient-text-blue` (lines ~304–310) + - `.gradient-text-purple` (lines ~312–318) + - `.gradient-text-pink` — does it exist? `rg` to confirm. + - `.gradient-text-gold` — keep ONLY if still consumed. + - `.gradient-text-flame` / `.gradient-text-ember` — keep ONLY if + still consumed; these are brand-aligned. +- Legacy glow utility classes: + - `[data-theme="dark"] .glow-blue` (line ~292) + - `[data-theme="dark"] .glow-purple` (line ~296) + - `[data-theme="dark"] .glow-pink` (line ~300) +- Legacy ad-hoc glow utilities (replaced by `--ember-rim-*` / + `--rim-light-*` tokens): + - `.fire-glow` (line ~209) — confirmed dropped by #5. + - `.ember-glow` (line ~213) — confirmed dropped by #5. +- Legacy gradient-bg utility classes — drop if unused post-migration: + - `.gradient-bg-fire` (line ~196) + - `.gradient-bg-golden` (line ~200) + - `.gradient-bg-ember` (line ~204) +- Legacy accent-color mappings: + - In `:root` (line ~115–118): `--accent-blue`, `--accent-purple`, + `--accent-pink` — delete; no consumer should remain. + - In `[data-theme="dark"]` (line ~146–149): same three vars. +- Legacy `.btn-*` utility classes — drop ONLY if #3 migrated every + consumer and operator confirms. Conservative default: keep `.btn-*` + as thin aliases of ` - - + ); } 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..9cfb005 100644 --- a/lib/use-focus-trap.js +++ b/lib/use-focus-trap.js @@ -10,19 +10,24 @@ function getFocusableElements(container) { } /** - * Trap focus inside a modal while `active` and restore focus on close. + * 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; + if (!active) return undefined; previouslyFocusedRef.current = document.activeElement; const container = containerRef.current; - if (!container) return; + if (!container) return undefined; const focusFirst = () => { const nodes = getFocusableElements(container); @@ -62,3 +67,78 @@ export function useFocusTrap(active) { return containerRef; } + +const NEW_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(','); + +/** + * 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(() => { + if (!active) return undefined; + previousActiveElement.current = document.activeElement; + + const container = containerRef.current; + if (!container) return undefined; + + const focusTarget = + initialFocusRef?.current ?? + container.querySelector(NEW_FOCUSABLE) ?? + container; + if (focusTarget && typeof focusTarget.focus === 'function') { + if (focusTarget === container && !container.hasAttribute('tabindex')) { + container.setAttribute('tabindex', '-1'); + } + focusTarget.focus({ preventScroll: true }); + } + + const handleKeyDown = (e) => { + if (e.key !== 'Tab') return; + const focusable = Array.from(container.querySelectorAll(NEW_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 (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + if ( + restoreFocus && + previousActiveElement.current && + typeof previousActiveElement.current.focus === 'function' + ) { + previousActiveElement.current.focus({ preventScroll: true }); + } + }; + }, [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(); + }); +});