From d6ba66f27f58d1c8051ce9cb2111f0ccc7ae2041 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 16:02:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(motion):=20@bytelyst/motion@0.2.0=20?= =?UTF-8?q?=E2=80=94=20Wave=2013.D=20spatial=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new primitives that unlock the §3.6 'visionOS-inspired surfaces' column of v3.1 — and the MAG.1 customer-magnet hero in §9.1. ────────────────────────────────────────────────────────────────── · cursor-tracking radial gradient ────────────────────────────────────────────────────────────────── packages/motion/src/Spotlight.tsx (new) - Tracks pointer via two CSS custom props (--bl-spot-x/y); zero React re-renders on move - color-mix(in srgb, var(--bl-accent) 22%, transparent) default - prefers-reduced-motion → renders static centred gradient - data-testid + data-reduced for Playwright + visual review ────────────────────────────────────────────────────────────────── · Arc-browser-style pointer attraction wrapper ────────────────────────────────────────────────────────────────── packages/motion/src/Magnetic.tsx (new) - Field radius (default 120 px) — child translates toward cursor only inside that radius, with strength clamp - window-level pointermove listener so the field works even before hover - SPRINGS.snappy transition on release (280 ms) - reduced-motion → transition: none, no translation ────────────────────────────────────────────────────────────────── · ambient OKLCH gradient ────────────────────────────────────────────────────────────────── packages/motion/src/MeshBackground.tsx (new) - 4-stop color-mix() palette (token-driven, sRGB fallback) - Three mood tiers: calm (24s) · focus (16s) · celebrate (10s) - Pure CSS keyframes (translate3d + scale) emitted inline - reduced-motion → renders static blobs (no + )} + {children} + + ); +} diff --git a/packages/motion/src/Spotlight.tsx b/packages/motion/src/Spotlight.tsx new file mode 100644 index 00000000..8756c86c --- /dev/null +++ b/packages/motion/src/Spotlight.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'; +import { prefersReducedMotion } from './utils.js'; + +export interface SpotlightProps { + /** Slot rendered inside the spotlight wrapper. */ + children: ReactNode; + /** Spotlight radius in px. Default 320. */ + size?: number; + /** Gradient center colour. Defaults to `var(--bl-accent)` with .22 alpha. */ + color?: string; + /** Background base colour beneath the spotlight. Defaults to transparent. */ + baseColor?: string; + /** Bypass motion entirely (renders static `baseColor`). */ + disableMotion?: boolean; + className?: string; + style?: CSSProperties; +} + +/** + * `` — radial gradient that follows the pointer. + * + * Wraps any block; on `pointermove` the gradient centre tracks the cursor + * via two CSS custom properties (`--bl-spot-x` / `--bl-spot-y`). The CSS + * variable update is the cheapest possible re-paint trigger — no React + * re-renders, no setState in the listener. + * + * Honours `prefers-reduced-motion` (renders the static base layer + * with the spotlight pinned to the centre). + * + * @example + * ```tsx + * + *

Hover me

+ *
+ * ``` + */ +export function Spotlight({ + children, + size = 320, + color = 'color-mix(in srgb, var(--bl-accent, #6366f1) 22%, transparent)', + baseColor = 'transparent', + disableMotion, + className, + style, +}: SpotlightProps) { + const ref = useRef(null); + const reduced = disableMotion ?? prefersReducedMotion(); + + useEffect(() => { + if (reduced) return; + const el = ref.current; + if (!el) return; + const onMove = (e: PointerEvent) => { + const rect = el.getBoundingClientRect(); + el.style.setProperty('--bl-spot-x', `${e.clientX - rect.left}px`); + el.style.setProperty('--bl-spot-y', `${e.clientY - rect.top}px`); + }; + const onLeave = () => { + el.style.removeProperty('--bl-spot-x'); + el.style.removeProperty('--bl-spot-y'); + }; + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerleave', onLeave); + return () => { + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerleave', onLeave); + }; + }, [reduced]); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/motion/src/__tests__/motion.test.tsx b/packages/motion/src/__tests__/motion.test.tsx index 13d79b1b..ee562483 100644 --- a/packages/motion/src/__tests__/motion.test.tsx +++ b/packages/motion/src/__tests__/motion.test.tsx @@ -171,3 +171,73 @@ describe('ScrollProgress', () => { remove.mockRestore(); }); }); + +// ── Wave 13.D spatial primitives ──────────────────────────────────────── + +import { Spotlight } from '../Spotlight.js'; +import { Magnetic } from '../Magnetic.js'; +import { MeshBackground } from '../MeshBackground.js'; + +describe('Spotlight', () => { + beforeEach(() => cleanup()); + it('renders children + data-testid', () => { + render(); + expect(screen.getByTestId('bl-spotlight')).toBeDefined(); + expect(screen.getByTestId('child')).toBeDefined(); + }); + it('disableMotion=true renders the reduced-motion (centered) gradient', () => { + render(); + const el = screen.getByTestId('bl-spotlight'); + expect(el.getAttribute('data-reduced')).toBe('true'); + }); + it('updates --bl-spot-x / --bl-spot-y on pointermove', () => { + render(); + const el = screen.getByTestId('bl-spotlight') as HTMLElement; + const evt = new Event('pointermove') as PointerEvent; + Object.assign(evt, { clientX: 120, clientY: 80 }); + el.dispatchEvent(evt); + // Property updates are best-effort; we just assert no throw. + expect(el).toBeDefined(); + }); +}); + +describe('Magnetic', () => { + beforeEach(() => cleanup()); + it('renders children inline-block', () => { + render(); + const el = screen.getByTestId('bl-magnetic') as HTMLElement; + expect(el.style.display).toBe('inline-block'); + }); + it('disableMotion bypasses transform transition', () => { + render(); + const el = screen.getByTestId('bl-magnetic') as HTMLElement; + expect(el.style.transition).toBe('none'); + }); +}); + +describe('MeshBackground', () => { + beforeEach(() => cleanup()); + it('renders children + records mood + reduced data attrs', () => { + render( + +

Title

+
, + ); + const el = screen.getByTestId('bl-mesh-background'); + expect(el.getAttribute('data-mood')).toBe('focus'); + expect(el.getAttribute('data-reduced')).toBe('true'); + expect(screen.getByTestId('hero').textContent).toBe('Title'); + }); + it('accepts custom 4-stop palette', () => { + render( + + + , + ); + // Just ensure it doesn't throw + renders the wrapper. + expect(screen.getByTestId('bl-mesh-background')).toBeDefined(); + }); +}); diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts index 5a094736..608db59a 100644 --- a/packages/motion/src/index.ts +++ b/packages/motion/src/index.ts @@ -1,16 +1,20 @@ /** - * @bytelyst/motion — Motion primitives (Wave 4). + * @bytelyst/motion — Motion primitives (Wave 4 + Wave 13.D). * - * Exports (0.1.0): + * Exports (0.1.0 — Wave 4 elegance): * — fade/slide on viewport entry * — sequenced reveal of children * — animated counter tween (RAF-based) * — interactive 3D hover with cursor-tracking glare * — fixed scroll-position bar * - * Coming in 0.2.x (per ROADMAP §Wave 4): - * , (View Transitions API), - * , + * New in 0.2.0 (Wave 13.D — spatial / visionOS-inspired surfaces): + * — radial gradient that follows the pointer + * — Arc-style pointer attraction wrapper + * — ambient OKLCH gradient with drifting blobs + * + * Coming in 0.3.x (per roadmap §13.D + §3.6): + * (scroll-driven), , * * All primitives: * - honor `prefers-reduced-motion` automatically @@ -34,5 +38,15 @@ export type { TiltCardProps } from './TiltCard.js'; export { ScrollProgress } from './ScrollProgress.js'; export type { ScrollProgressProps } from './ScrollProgress.js'; +// ── Wave 13.D — spatial primitives ────────────────────────────────────── +export { Spotlight } from './Spotlight.js'; +export type { SpotlightProps } from './Spotlight.js'; + +export { Magnetic } from './Magnetic.js'; +export type { MagneticProps } from './Magnetic.js'; + +export { MeshBackground } from './MeshBackground.js'; +export type { MeshBackgroundProps } from './MeshBackground.js'; + export { SPRINGS, prefersReducedMotion } from './utils.js'; export type { Spring } from './utils.js';