diff --git a/docs/UI_ROADMAP_2026_V3_CROSS_REPO.md b/docs/UI_ROADMAP_2026_V3_CROSS_REPO.md index 5c5cbf49..fd663268 100644 --- a/docs/UI_ROADMAP_2026_V3_CROSS_REPO.md +++ b/docs/UI_ROADMAP_2026_V3_CROSS_REPO.md @@ -612,16 +612,16 @@ For multi-step rows, sub-bullets are tracked independently. Agents should leave ### 11.2 Progress at a glance ``` -TOTAL 14 / 202 ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 7% +TOTAL 19 / 202 ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 9% โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Wave 8 Rollout 5 / 18 ๐ŸŸฉ๐ŸŸฉ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 28% Wave 9 Data 9 / 42 ๐ŸŸฉ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 21% Wave 10 Shells 0 / 35 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% Wave 11 Adaptive 0 / 26 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% Wave 12 Mobile 0 / 26 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% -Wave 13 Futurism 0 / 39 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% +Wave 13 Futurism 4 / 39 ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 10% Cross-cutting 0 / 8 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% -Magnet demos 0 / 8 โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 0% +Magnet demos 1 / 8 ๐ŸŸฉโฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ›โฌ› 13% ``` > **Agents:** before pushing your commit, run `pnpm dlx tsx scripts/count-roadmap-progress.ts docs/UI_ROADMAP_2026_V3_CROSS_REPO.md` (to be authored in Wave 8.0) and paste the refreshed block in. @@ -878,7 +878,7 @@ Numbered list โ€” coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the p - [ ] **12.F.3** `pnpm create @bytelyst/app my-app` scaffolds a working dev server < 60s - [ ] **12.F.4** **Showcase:** `/showcase/sustainability/budget-card` โ€” visualises live page COโ‚‚ -### 11.8 Wave 13 โ€” Futurism layer ยท `0 / 39` +### 11.8 Wave 13 โ€” Futurism layer ยท `4 / 39` #### 13.A ยท `@bytelyst/on-device-ai@0.1.0` @@ -913,12 +913,12 @@ Numbered list โ€” coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the p #### 13.D ยท `motion@0.2.0` spatial primitives -- [ ] **13.D.1** `` (scroll-driven, zero-JS where supported) + tests -- [ ] **13.D.2** `` (cursor-follow radial gradient) + tests -- [ ] **13.D.3** `` (Arc-style pointer-attracted button) + tests -- [ ] **13.D.4** `` (OKLCH ambient that shifts with time-of-day) + tests -- [ ] **13.D.5** `` + tests -- [ ] **13.D.6** **Showcase:** `/showcase/futurism/spatial-hero` โ€” full marketing-grade landing page +- [ ] **13.D.1** `` (scroll-driven, zero-JS where supported) + tests _(deferred to 0.3.x)_ +- [x] **13.D.2** `` (cursor-follow radial gradient via two CSS custom props, no React re-render) + 3 tests _(common_plat motion@0.2.0 ยท 23/23 passing)_ +- [x] **13.D.3** `` (Arc-style pointer-attracted wrapper with field-radius + clamped strength + reduced-motion fallback) + 2 tests _(common_plat motion@0.2.0)_ +- [x] **13.D.4** `` (4-stop OKLCH gradient with drifting blobs ยท 3 mood tiers ยท reduced-motion static fallback) + 2 tests _(common_plat motion@0.2.0)_ +- [ ] **13.D.5** `` + tests _(deferred to 0.3.x โ€” existing `` already in 0.1.0 covers the single-card case)_ +- [x] **13.D.6** **Showcase:** `/showcase/futurism/spatial-hero` โ€” full marketing-grade landing page with MeshBackground + Spotlight + 2 Magnetic CTAs + 4 NumberFlow KPIs + StaggerList #### 13.E ยท `@bytelyst/generative-theme@0.1.0` @@ -955,7 +955,7 @@ Numbered list โ€” coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the p Each is the _capstone_ demo of its package family. Marketing-grade. -- [ ] **MAG.1** `/showcase/futurism/spatial-hero` โ€” `` + `` landing hero (Wave 13.D.6) +- [x] **MAG.1** `/showcase/futurism/spatial-hero` โ€” `` + `` landing hero (Wave 13.D.6) **โœจ the customer-magnet hero is live** - [ ] **MAG.2** `/showcase/futurism/on-device-chat` โ€” fully-local chat with honest `` (Wave 13.A.7) - [ ] **MAG.3** `/showcase/futurism/trust-surfaces` โ€” `` + `` + `` dashboard (Wave 13.C.7) - [ ] **MAG.4** `/showcase/futurism/crdt-notes` โ€” open two windows, watch them sync (Wave 13.B.6) diff --git a/packages/motion/package.json b/packages/motion/package.json index c999f493..3cae88ae 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,8 +1,8 @@ { "name": "@bytelyst/motion", - "version": "0.1.0", + "version": "0.2.0", "type": "module", - "description": "Motion primitives โ€” Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress. ~5 KB gzip, honors prefers-reduced-motion.", + "description": "Motion primitives โ€” Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress, Spotlight, Magnetic, MeshBackground. Honors prefers-reduced-motion.", "exports": { ".": { "import": "./dist/index.js", diff --git a/packages/motion/src/Magnetic.tsx b/packages/motion/src/Magnetic.tsx new file mode 100644 index 00000000..c094182c --- /dev/null +++ b/packages/motion/src/Magnetic.tsx @@ -0,0 +1,91 @@ +import { + useEffect, + useRef, + type CSSProperties, + type ReactNode, +} from 'react'; +import { prefersReducedMotion, SPRINGS } from './utils.js'; + +export interface MagneticProps { + /** Child element โ€” typically a button or link. */ + children: ReactNode; + /** Maximum translation in px. Default 12. */ + strength?: number; + /** Activation radius around the element. Default 120 px. */ + radius?: number; + /** Bypass motion entirely (renders without transform). */ + disableMotion?: boolean; + className?: string; + style?: CSSProperties; +} + +/** + * `` โ€” Arc-browser-style pointer attraction wrapper. As the + * cursor enters a `radius` around the element, the child translates + * toward it (clamped to `strength`). Resets smoothly on pointer leave. + * + * Implementation note: we attach pointer listeners to **window**, not + * the wrapped element, so the magnet has a "field" โ€” the cursor pulls + * the child even before hovering it. + */ +export function Magnetic({ + children, + strength = 12, + radius = 120, + disableMotion, + className, + style, +}: MagneticProps) { + 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(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = e.clientX - cx; + const dy = e.clientY - cy; + const dist = Math.hypot(dx, dy); + if (dist > radius) { + el.style.transform = 'translate3d(0,0,0)'; + return; + } + const f = (1 - dist / radius) * strength; + const tx = (dx / Math.max(1, dist)) * f; + const ty = (dy / Math.max(1, dist)) * f; + el.style.transform = `translate3d(${tx}px, ${ty}px, 0)`; + }; + const onLeave = () => { + el.style.transform = 'translate3d(0,0,0)'; + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerleave', onLeave); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerleave', onLeave); + }; + }, [reduced, radius, strength]); + + return ( + + {children} + + ); +} diff --git a/packages/motion/src/MeshBackground.tsx b/packages/motion/src/MeshBackground.tsx new file mode 100644 index 00000000..404ce08e --- /dev/null +++ b/packages/motion/src/MeshBackground.tsx @@ -0,0 +1,101 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { prefersReducedMotion } from './utils.js'; + +export interface MeshBackgroundProps { + /** Optional content rendered on top of the mesh. */ + children?: ReactNode; + /** + * Mood tier โ€” controls the overall saturation + animation cadence. + * - `calm` โ€” slow drift, low saturation. Default. + * - `focus` โ€” slightly faster + a touch more saturation. + * - `celebrate` โ€” vibrant, faster oscillation. Use sparingly. + */ + mood?: 'calm' | 'focus' | 'celebrate'; + /** Override the gradient stop colour palette (CSS colours). */ + colors?: [string, string, string, string]; + /** Bypass animation (static mesh) โ€” respects prefers-reduced-motion. */ + disableMotion?: boolean; + className?: string; + style?: CSSProperties; +} + +/** + * `` โ€” ambient OKLCH-flavoured gradient wash with two + * blurred conic "blobs" drifting slowly. Pure CSS, zero JS once mounted. + * + * Designed as a background layer behind landing-page heroes. Subtle by + * default (calm mood); never use for content surfaces (kills contrast). + * + * Honours `prefers-reduced-motion`: the blobs render static when the + * user opts out. + */ +export function MeshBackground({ + children, + mood = 'calm', + colors, + disableMotion, + className, + style, +}: MeshBackgroundProps) { + const reduced = disableMotion ?? prefersReducedMotion(); + const palette = + colors ?? + ([ + // Default palette uses tokens with sensible sRGB fallbacks. + 'color-mix(in srgb, var(--bl-accent, #6366f1) 60%, transparent)', + 'color-mix(in srgb, var(--bl-success, #34d399) 50%, transparent)', + 'color-mix(in srgb, var(--bl-warning, #fbbf24) 50%, transparent)', + 'color-mix(in srgb, var(--bl-danger, #f472b6) 50%, transparent)', + ] as const); + + const speed: Record, string> = { + calm: '24s', + focus: '16s', + celebrate: '10s', + }; + + return ( +
+ {/* Two blurred blobs drifting on independent paths. */} + + ); +} 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';