feat(motion): @bytelyst/motion@0.2.0 — Wave 13.D spatial primitives
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.
──────────────────────────────────────────────────────────────────
<Spotlight> · 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
──────────────────────────────────────────────────────────────────
<Magnetic> · 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
──────────────────────────────────────────────────────────────────
<MeshBackground> · 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 <style>)
- isolation: isolate so children get their own stacking context
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ pnpm -F @bytelyst/motion test → 23/23 passing (was 16/16)
+7 new cases: Spotlight (3) · Magnetic (2) · MeshBackground (2)
✓ pnpm -F @bytelyst/motion typecheck → clean
✓ Exports wired in src/index.ts; doc-block bumped to mention
Wave 13.D additions; package.json 0.1.0 → 0.2.0
✓ Description string lists all 8 primitives
──────────────────────────────────────────────────────────────────
Roadmap tracker — 5 boxes flipped (§11)
──────────────────────────────────────────────────────────────────
13.D.2 Spotlight shipped
13.D.3 Magnetic shipped
13.D.4 MeshBackground shipped
13.D.6 spatial-hero showcase (lands in paired showcase commit)
MAG.1 the customer-magnet hero ✨
13.D.1 (Parallax) + 13.D.5 (TiltGallery) explicitly deferred to
motion@0.3.x with notes in the tracker.
Wave 13 Futurism: 0/39 → 5/39 (13%) · TOTAL 14/202 → 19/202 (9%)
Vendored snapshot + showcase /futurism routes land in the paired
showcase commit.
This commit is contained in:
parent
8e98cb1acb
commit
d6ba66f27f
@ -612,16 +612,16 @@ For multi-step rows, sub-bullets are tracked independently. Agents should leave
|
|||||||
### 11.2 Progress at a glance
|
### 11.2 Progress at a glance
|
||||||
|
|
||||||
```
|
```
|
||||||
TOTAL 14 / 202 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 7%
|
TOTAL 19 / 202 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 9%
|
||||||
─────────────────────────────────────────────
|
─────────────────────────────────────────────
|
||||||
Wave 8 Rollout 5 / 18 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 28%
|
Wave 8 Rollout 5 / 18 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 28%
|
||||||
Wave 9 Data 9 / 42 🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛ 21%
|
Wave 9 Data 9 / 42 🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛ 21%
|
||||||
Wave 10 Shells 0 / 35 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
Wave 10 Shells 0 / 35 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||||
Wave 11 Adaptive 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
Wave 11 Adaptive 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||||
Wave 12 Mobile 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%
|
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.
|
> **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.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₂
|
- [ ] **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`
|
#### 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 · `motion@0.2.0` spatial primitives
|
||||||
|
|
||||||
- [ ] **13.D.1** `<Parallax>` (scroll-driven, zero-JS where supported) + tests
|
- [ ] **13.D.1** `<Parallax>` (scroll-driven, zero-JS where supported) + tests _(deferred to 0.3.x)_
|
||||||
- [ ] **13.D.2** `<Spotlight>` (cursor-follow radial gradient) + tests
|
- [x] **13.D.2** `<Spotlight>` (cursor-follow radial gradient via two CSS custom props, no React re-render) + 3 tests _(common_plat motion@0.2.0 · 23/23 passing)_
|
||||||
- [ ] **13.D.3** `<Magnetic>` (Arc-style pointer-attracted button) + tests
|
- [x] **13.D.3** `<Magnetic>` (Arc-style pointer-attracted wrapper with field-radius + clamped strength + reduced-motion fallback) + 2 tests _(common_plat motion@0.2.0)_
|
||||||
- [ ] **13.D.4** `<MeshBackground>` (OKLCH ambient that shifts with time-of-day) + tests
|
- [x] **13.D.4** `<MeshBackground>` (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** `<TiltGallery>` + tests
|
- [ ] **13.D.5** `<TiltGallery>` + tests _(deferred to 0.3.x — existing `<TiltCard>` already in 0.1.0 covers the single-card case)_
|
||||||
- [ ] **13.D.6** **Showcase:** `/showcase/futurism/spatial-hero` — full marketing-grade landing page
|
- [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`
|
#### 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.
|
Each is the _capstone_ demo of its package family. Marketing-grade.
|
||||||
|
|
||||||
- [ ] **MAG.1** `/showcase/futurism/spatial-hero` — `<MeshBackground>` + `<Spotlight>` landing hero (Wave 13.D.6)
|
- [x] **MAG.1** `/showcase/futurism/spatial-hero` — `<MeshBackground>` + `<Spotlight>` landing hero (Wave 13.D.6) **✨ the customer-magnet hero is live**
|
||||||
- [ ] **MAG.2** `/showcase/futurism/on-device-chat` — fully-local chat with honest `<PrivacyBadge>` (Wave 13.A.7)
|
- [ ] **MAG.2** `/showcase/futurism/on-device-chat` — fully-local chat with honest `<PrivacyBadge>` (Wave 13.A.7)
|
||||||
- [ ] **MAG.3** `/showcase/futurism/trust-surfaces` — `<CostMeter>` + `<ConfidenceTag>` + `<ProvenanceDrawer>` dashboard (Wave 13.C.7)
|
- [ ] **MAG.3** `/showcase/futurism/trust-surfaces` — `<CostMeter>` + `<ConfidenceTag>` + `<ProvenanceDrawer>` dashboard (Wave 13.C.7)
|
||||||
- [ ] **MAG.4** `/showcase/futurism/crdt-notes` — open two windows, watch them sync (Wave 13.B.6)
|
- [ ] **MAG.4** `/showcase/futurism/crdt-notes` — open two windows, watch them sync (Wave 13.B.6)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@bytelyst/motion",
|
"name": "@bytelyst/motion",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"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": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
|
|||||||
91
packages/motion/src/Magnetic.tsx
Normal file
91
packages/motion/src/Magnetic.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<Magnetic>` — 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<HTMLSpanElement>(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 (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
data-testid="bl-magnetic"
|
||||||
|
data-reduced={reduced ? 'true' : 'false'}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
willChange: 'transform',
|
||||||
|
transition: reduced
|
||||||
|
? 'none'
|
||||||
|
: `transform 280ms ${SPRINGS.snappy}`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
packages/motion/src/MeshBackground.tsx
Normal file
101
packages/motion/src/MeshBackground.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<MeshBackground>` — 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<NonNullable<MeshBackgroundProps['mood']>, string> = {
|
||||||
|
calm: '24s',
|
||||||
|
focus: '16s',
|
||||||
|
celebrate: '10s',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-mesh-background"
|
||||||
|
data-mood={mood}
|
||||||
|
data-reduced={reduced ? 'true' : 'false'}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
isolation: 'isolate',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Two blurred blobs drifting on independent paths. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-20%',
|
||||||
|
backgroundImage: [
|
||||||
|
`radial-gradient(40% 35% at 20% 25%, ${palette[0]}, transparent 70%)`,
|
||||||
|
`radial-gradient(35% 30% at 80% 30%, ${palette[1]}, transparent 70%)`,
|
||||||
|
`radial-gradient(45% 40% at 30% 80%, ${palette[2]}, transparent 70%)`,
|
||||||
|
`radial-gradient(40% 35% at 75% 75%, ${palette[3]}, transparent 70%)`,
|
||||||
|
].join(', '),
|
||||||
|
filter: 'blur(48px)',
|
||||||
|
opacity: 0.85,
|
||||||
|
animation: reduced ? undefined : `bl-mesh-drift ${speed[mood]} ease-in-out infinite alternate`,
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Keyframes are global because we don't want a styled-components
|
||||||
|
dependency. Authoring them via a <style> tag keeps the package
|
||||||
|
framework-agnostic. */}
|
||||||
|
{!reduced && (
|
||||||
|
<style>{`@keyframes bl-mesh-drift {
|
||||||
|
0% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
50% { transform: translate3d(2%, -2%, 0) scale(1.05); }
|
||||||
|
100% { transform: translate3d(-2%, 2%, 0) scale(0.98); }
|
||||||
|
}`}</style>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
packages/motion/src/Spotlight.tsx
Normal file
91
packages/motion/src/Spotlight.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<Spotlight>` — 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
|
||||||
|
* <Spotlight>
|
||||||
|
* <h1>Hover me</h1>
|
||||||
|
* </Spotlight>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-testid="bl-spotlight"
|
||||||
|
data-reduced={reduced ? 'true' : 'false'}
|
||||||
|
className={className}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
background: baseColor,
|
||||||
|
backgroundImage: reduced
|
||||||
|
? `radial-gradient(${size}px circle at 50% 50%, ${color}, transparent 60%)`
|
||||||
|
: `radial-gradient(${size}px circle at var(--bl-spot-x, 50%) var(--bl-spot-y, 50%), ${color}, transparent 60%)`,
|
||||||
|
transition: 'background 200ms ease',
|
||||||
|
...style,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -171,3 +171,73 @@ describe('ScrollProgress', () => {
|
|||||||
remove.mockRestore();
|
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(<Spotlight><span data-testid="child" /></Spotlight>);
|
||||||
|
expect(screen.getByTestId('bl-spotlight')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('child')).toBeDefined();
|
||||||
|
});
|
||||||
|
it('disableMotion=true renders the reduced-motion (centered) gradient', () => {
|
||||||
|
render(<Spotlight disableMotion><span /></Spotlight>);
|
||||||
|
const el = screen.getByTestId('bl-spotlight');
|
||||||
|
expect(el.getAttribute('data-reduced')).toBe('true');
|
||||||
|
});
|
||||||
|
it('updates --bl-spot-x / --bl-spot-y on pointermove', () => {
|
||||||
|
render(<Spotlight><span /></Spotlight>);
|
||||||
|
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(<Magnetic><button>hi</button></Magnetic>);
|
||||||
|
const el = screen.getByTestId('bl-magnetic') as HTMLElement;
|
||||||
|
expect(el.style.display).toBe('inline-block');
|
||||||
|
});
|
||||||
|
it('disableMotion bypasses transform transition', () => {
|
||||||
|
render(<Magnetic disableMotion><button>hi</button></Magnetic>);
|
||||||
|
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(
|
||||||
|
<MeshBackground mood="focus" disableMotion>
|
||||||
|
<h1 data-testid="hero">Title</h1>
|
||||||
|
</MeshBackground>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<MeshBackground
|
||||||
|
colors={['red', 'orange', 'yellow', 'green']}
|
||||||
|
disableMotion
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</MeshBackground>,
|
||||||
|
);
|
||||||
|
// Just ensure it doesn't throw + renders the wrapper.
|
||||||
|
expect(screen.getByTestId('bl-mesh-background')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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):
|
||||||
* <Reveal> — fade/slide on viewport entry
|
* <Reveal> — fade/slide on viewport entry
|
||||||
* <StaggerList> — sequenced reveal of children
|
* <StaggerList> — sequenced reveal of children
|
||||||
* <NumberFlow> — animated counter tween (RAF-based)
|
* <NumberFlow> — animated counter tween (RAF-based)
|
||||||
* <TiltCard> — interactive 3D hover with cursor-tracking glare
|
* <TiltCard> — interactive 3D hover with cursor-tracking glare
|
||||||
* <ScrollProgress> — fixed scroll-position bar
|
* <ScrollProgress> — fixed scroll-position bar
|
||||||
*
|
*
|
||||||
* Coming in 0.2.x (per ROADMAP §Wave 4):
|
* New in 0.2.0 (Wave 13.D — spatial / visionOS-inspired surfaces):
|
||||||
* <Magnetic>, <PageTransition> (View Transitions API),
|
* <Spotlight> — radial gradient that follows the pointer
|
||||||
* <Drag>, <SwipeToDismiss>
|
* <Magnetic> — Arc-style pointer attraction wrapper
|
||||||
|
* <MeshBackground> — ambient OKLCH gradient with drifting blobs
|
||||||
|
*
|
||||||
|
* Coming in 0.3.x (per roadmap §13.D + §3.6):
|
||||||
|
* <Parallax> (scroll-driven), <TiltGallery>, <PageTransition>
|
||||||
*
|
*
|
||||||
* All primitives:
|
* All primitives:
|
||||||
* - honor `prefers-reduced-motion` automatically
|
* - honor `prefers-reduced-motion` automatically
|
||||||
@ -34,5 +38,15 @@ export type { TiltCardProps } from './TiltCard.js';
|
|||||||
export { ScrollProgress } from './ScrollProgress.js';
|
export { ScrollProgress } from './ScrollProgress.js';
|
||||||
export type { ScrollProgressProps } 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 { SPRINGS, prefersReducedMotion } from './utils.js';
|
||||||
export type { Spring } from './utils.js';
|
export type { Spring } from './utils.js';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user