learning_ai_common_plat/packages/motion/src/TiltGallery.tsx
saravanakumardb1 87e3bc490a feat: Wave 9.E (ai-ui@0.6.0) + Wave 13.D.1/.5 (motion@0.2.1)
──────────────────────────────────────────────────────────────────
motion@0.2.1 — Parallax + TiltGallery
──────────────────────────────────────────────────────────────────
  + Parallax.tsx
      - scroll-driven translate3d via rAF + window.scroll
      - speed multiplier + axis (y / x) + reduced-motion bypass
      - listener cleanup + cancelAnimationFrame on unmount
      - WAVE 13.D.1
  + TiltGallery.tsx
      - horizontally-scrolling rail of <TiltCard>-style tiles
      - per-tile cursor-tracking rotateX/Y + glare gradient
      - role=region + arrow-key scrolling (←/→) + scroll-snap
      - reduced-motion strips tilt + glare, keeps the rail
      - WAVE 13.D.5
  + 5 new tests (Parallax x 2, TiltGallery x 3) — 28/28 passing
  + index.ts: exports both + types
  + package.json: 0.2.0 → 0.2.1

──────────────────────────────────────────────────────────────────
ai-ui@0.6.0 — Wave 9.E composition surfaces
──────────────────────────────────────────────────────────────────
  + Markdown.tsx
      - dep-free subset renderer: h1-h3 / **bold** / *italic* /
        `code` / fenced code / ul + ol / [text](url)
      - inline `[cite:<id>]` chips resolved from a citations
        registry (missing ids render as [?] — failure mode is loud)
      - WAVE 9.E.1
  + CodeDiff.tsx
      - line-LCS diff in <100 LOC, zero deps
      - split (2-col) and unified views; tinted add/del rows
      - WAVE 9.E.2
  + ExplainThis.tsx
      - listens for selectionchange, pops 'Explain' CTA over the
        selection rect when inside the wrapper + ≥ minLength chars
      - fires onExplain({ text, rect }) so hosts can open a richer
        side panel if preferred
      - WAVE 9.E.3
  + usePromptHistory.ts
      - bash-style ↑/↓ recall with localStorage persistence
        (storage key configurable; null = in-memory for tests/SSR)
      - dedupes consecutive duplicates + trims to capacity
      - WAVE 9.E.4
  + useTokenCount.ts
      - cheap estimator (default ~4 chars/token; configurable for
        code/CJK) + optional USD cost
      - memoised — stable across re-renders
      - WAVE 9.E.5
  + 19 new tests in src/__tests__/composition.test.tsx — 98/98 passing
  + index.ts: '0.6 surfaces' section exports all 5 + types
  + package.json: 0.5.0 → 0.6.0

Showcase routes + roadmap flips land in the paired showcase commit.
2026-05-27 16:59:28 -07:00

219 lines
5.7 KiB
TypeScript

import {
useCallback,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { prefersReducedMotion } from './utils.js';
export interface TiltGalleryItem {
/** Stable id — React key + aria-controls. */
id: string;
/** Body slot for the tile. */
content: ReactNode;
/** Optional caption rendered under the tile. */
caption?: ReactNode;
}
export interface TiltGalleryProps {
/** The tiles to render. */
items: TiltGalleryItem[];
/** Maximum tilt angle in degrees (each axis). Default 8. */
maxTilt?: number;
/** Width of each tile in px. Default 280. */
tileWidth?: number;
/** Height of each tile in px. Default 200. */
tileHeight?: number;
/** Gap between tiles in px. Default 16. */
gap?: number;
/** Bypass tilt + glare entirely — useful for tests / reduced-motion. */
disableMotion?: boolean;
/** Accessible label for the gallery region. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<TiltGallery>` — horizontally-scrolling gallery of `<TiltCard>`-style
* tiles. Each tile tilts toward the cursor on hover; the gallery itself
* supports keyboard arrow scrolling and respects
* `prefers-reduced-motion` for the tilt effect.
*
* Wave 13.D.5. Multi-card sibling of the existing `<TiltCard>` — built
* on the same cursor-tracking math but with a single React component
* orchestrating the whole row.
*/
export function TiltGallery({
items,
maxTilt = 8,
tileWidth = 280,
tileHeight = 200,
gap = 16,
disableMotion,
ariaLabel = 'Tilt gallery',
className,
style,
}: TiltGalleryProps) {
const reduced = disableMotion ?? prefersReducedMotion();
const railRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const onArrow = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
const rail = railRef.current;
if (!rail) return;
if (e.key === 'ArrowRight') {
e.preventDefault();
rail.scrollBy({ left: tileWidth + gap, behavior: 'smooth' });
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
rail.scrollBy({ left: -(tileWidth + gap), behavior: 'smooth' });
}
},
[tileWidth, gap],
);
return (
<div
ref={railRef}
role="region"
aria-label={ariaLabel}
tabIndex={0}
onKeyDown={onArrow}
data-testid="bl-tilt-gallery"
data-reduced={reduced ? 'true' : 'false'}
className={className}
style={{
display: 'flex',
gap,
overflowX: 'auto',
scrollSnapType: 'x mandatory',
padding: 4,
outline: 'none',
...style,
}}
>
{items.map((item, i) => (
<Tile
key={item.id}
id={`${baseId}-${i}`}
item={item}
maxTilt={maxTilt}
width={tileWidth}
height={tileHeight}
reduced={reduced}
/>
))}
</div>
);
}
function Tile({
id,
item,
maxTilt,
width,
height,
reduced,
}: {
id: string;
item: TiltGalleryItem;
maxTilt: number;
width: number;
height: number;
reduced: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [tilt, setTilt] = useState<{ rx: number; ry: number; gx: number; gy: number }>({
rx: 0,
ry: 0,
gx: 50,
gy: 50,
});
useEffect(() => {
if (reduced) return;
const el = ref.current;
if (!el) return;
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; // 0..1
const y = (e.clientY - rect.top) / rect.height;
const ry = (x - 0.5) * 2 * maxTilt; // -max..max
const rx = -(y - 0.5) * 2 * maxTilt;
setTilt({ rx, ry, gx: x * 100, gy: y * 100 });
};
const onLeave = () => setTilt({ rx: 0, ry: 0, gx: 50, gy: 50 });
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerleave', onLeave);
return () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerleave', onLeave);
};
}, [reduced, maxTilt]);
return (
<figure
data-testid="bl-tilt-gallery-tile"
id={id}
style={{
flex: '0 0 auto',
margin: 0,
scrollSnapAlign: 'start',
width,
perspective: 800,
}}
>
<div
ref={ref}
style={{
position: 'relative',
width,
height,
borderRadius: 16,
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
background: 'var(--bl-surface-card, #fff)',
overflow: 'hidden',
transform: reduced
? undefined
: `rotateX(${tilt.rx.toFixed(2)}deg) rotateY(${tilt.ry.toFixed(2)}deg)`,
transition: reduced ? undefined : 'transform 160ms ease',
willChange: reduced ? undefined : 'transform',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{item.content}
</div>
{!reduced && (
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background: `radial-gradient(120px circle at ${tilt.gx}% ${tilt.gy}%, color-mix(in srgb, var(--bl-accent, #6366f1) 18%, transparent), transparent 70%)`,
transition: 'background 120ms ease',
}}
/>
)}
</div>
{item.caption && (
<figcaption
style={{
marginTop: 8,
fontSize: 12,
color: 'var(--bl-text-tertiary, #999)',
}}
>
{item.caption}
</figcaption>
)}
</figure>
);
}