──────────────────────────────────────────────────────────────────
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.
219 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|