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.
This commit is contained in:
parent
ec9e11b243
commit
87e3bc490a
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bytelyst/ai-ui",
|
"name": "@bytelyst/ai-ui",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
224
packages/ai-ui/src/CodeDiff.tsx
Normal file
224
packages/ai-ui/src/CodeDiff.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export interface CodeDiffProps {
|
||||||
|
/** Original code. */
|
||||||
|
before: string;
|
||||||
|
/** Modified code. */
|
||||||
|
after: string;
|
||||||
|
/** Display mode. Default `'split'` (two columns). */
|
||||||
|
view?: 'split' | 'unified';
|
||||||
|
/** Optional language hint (no syntax highlighting yet, kept for parity). */
|
||||||
|
language?: string;
|
||||||
|
/** Filename or label rendered in the header (e.g. `useChat.ts`). */
|
||||||
|
filename?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffOp = { kind: 'eq' | 'add' | 'del'; text: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<CodeDiff>` — pure-React diff renderer with `split` and `unified`
|
||||||
|
* views. Internal diff is a small line-LCS implementation (no runtime
|
||||||
|
* dependency), good enough for short hunks shown in chat surfaces and
|
||||||
|
* tracker PR cards.
|
||||||
|
*
|
||||||
|
* Wave 9.E.2. Hosts that need byte-perfect Myers diff or syntax
|
||||||
|
* highlighting should feed `<CodeDiff>` a pre-computed pair where the
|
||||||
|
* server has already done the heavy lift.
|
||||||
|
*/
|
||||||
|
export function CodeDiff({
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
view = 'split',
|
||||||
|
language,
|
||||||
|
filename,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: CodeDiffProps) {
|
||||||
|
const ops = diffLines(before, after);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-code-diff"
|
||||||
|
data-view={view}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'var(--bl-surface-muted, #f6f6f6)',
|
||||||
|
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>{filename ?? 'diff'}</span>
|
||||||
|
<span style={{ opacity: 0.6 }}>
|
||||||
|
{language ? `${language} · ` : ''}
|
||||||
|
{view}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{view === 'split' ? (
|
||||||
|
<SplitView ops={ops} />
|
||||||
|
) : (
|
||||||
|
<UnifiedView ops={ops} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplitView({ ops }: { ops: DiffOp[] }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-testid="bl-code-diff-left" style={paneStyle('left')}>
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
if (op.kind === 'add') return null;
|
||||||
|
return (
|
||||||
|
<Row key={`l${i}`} kind={op.kind} side="left" text={op.text} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div data-testid="bl-code-diff-right" style={paneStyle('right')}>
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
if (op.kind === 'del') return null;
|
||||||
|
return (
|
||||||
|
<Row key={`r${i}`} kind={op.kind} side="right" text={op.text} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnifiedView({ ops }: { ops: DiffOp[] }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-code-diff-unified"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ops.map((op, i) => (
|
||||||
|
<Row key={`u${i}`} kind={op.kind} side="unified" text={op.text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
kind,
|
||||||
|
text,
|
||||||
|
side,
|
||||||
|
}: {
|
||||||
|
kind: 'eq' | 'add' | 'del';
|
||||||
|
side: 'left' | 'right' | 'unified';
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
const bg =
|
||||||
|
kind === 'add'
|
||||||
|
? 'color-mix(in srgb, var(--bl-success, #10b981) 14%, transparent)'
|
||||||
|
: kind === 'del'
|
||||||
|
? 'color-mix(in srgb, var(--bl-danger, #ef4444) 14%, transparent)'
|
||||||
|
: 'transparent';
|
||||||
|
const gutter = kind === 'add' ? '+' : kind === 'del' ? '−' : ' ';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-code-diff-row"
|
||||||
|
data-kind={kind}
|
||||||
|
data-side={side}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '20px 1fr',
|
||||||
|
background: bg,
|
||||||
|
padding: '0 8px',
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: 'var(--bl-text-tertiary, #888)', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{gutter}
|
||||||
|
</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneStyle(side: 'left' | 'right'): CSSProperties {
|
||||||
|
return {
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
borderRight:
|
||||||
|
side === 'left'
|
||||||
|
? '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))'
|
||||||
|
: undefined,
|
||||||
|
overflowX: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny line-level LCS — O(n·m) memory, fine for hunks <2,000 lines.
|
||||||
|
* Hosts that diff full files should pre-compute with a real Myers
|
||||||
|
* implementation and split into <CodeDiff> per hunk.
|
||||||
|
*/
|
||||||
|
function diffLines(before: string, after: string): DiffOp[] {
|
||||||
|
const a = before.split('\n');
|
||||||
|
const b = after.split('\n');
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
// Build LCS length table.
|
||||||
|
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
new Array<number>(m + 1).fill(0),
|
||||||
|
);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
dp[i]![j] = (dp[i + 1]?.[j + 1] ?? 0) + 1;
|
||||||
|
} else {
|
||||||
|
dp[i]![j] = Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Walk back to recover ops.
|
||||||
|
const ops: DiffOp[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
ops.push({ kind: 'eq', text: a[i] ?? '' });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
|
||||||
|
ops.push({ kind: 'del', text: a[i] ?? '' });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
ops.push({ kind: 'add', text: b[j] ?? '' });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) ops.push({ kind: 'del', text: a[i++] ?? '' });
|
||||||
|
while (j < m) ops.push({ kind: 'add', text: b[j++] ?? '' });
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
133
packages/ai-ui/src/ExplainThis.tsx
Normal file
133
packages/ai-ui/src/ExplainThis.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface ExplainThisProps {
|
||||||
|
/** Slot whose selected text is the explanation target. */
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Called when the user hits the "Explain" CTA. Receive the selected
|
||||||
|
* text + a structured rect describing where the popover anchored
|
||||||
|
* (useful for hosts that want to open a richer side-panel instead).
|
||||||
|
*/
|
||||||
|
onExplain: (selection: { text: string; rect: DOMRect | null }) => void;
|
||||||
|
/** Label on the floating button. Default "Explain". */
|
||||||
|
ctaLabel?: string;
|
||||||
|
/** Min characters required before the popover shows. Default 4. */
|
||||||
|
minLength?: number;
|
||||||
|
/** Bypass entirely. */
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<ExplainThis>` — highlight any text inside the wrapped slot, a small
|
||||||
|
* popover appears with an "Explain" CTA, the host calls the LLM with
|
||||||
|
* the selected substring.
|
||||||
|
*
|
||||||
|
* Wave 9.E.3.
|
||||||
|
*
|
||||||
|
* Implementation: listens for `selectionchange` on the document while
|
||||||
|
* mounted, only acts on selections wholly within our wrapper. Pops a
|
||||||
|
* fixed-position button positioned at the centre of the selection
|
||||||
|
* rect. Clears + hides itself when the selection collapses.
|
||||||
|
*/
|
||||||
|
export function ExplainThis({
|
||||||
|
children,
|
||||||
|
onExplain,
|
||||||
|
ctaLabel = 'Explain',
|
||||||
|
minLength = 4,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: ExplainThisProps) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pop, setPop] = useState<{ x: number; y: number; text: string; rect: DOMRect } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
setPop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onSelect = () => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||||
|
setPop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
// Ignore selections that aren't fully inside our wrapper.
|
||||||
|
if (!wrapper.contains(range.commonAncestorContainer)) {
|
||||||
|
setPop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = sel.toString().trim();
|
||||||
|
if (text.length < minLength) {
|
||||||
|
setPop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
setPop({
|
||||||
|
text,
|
||||||
|
rect,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top - 8,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('selectionchange', onSelect);
|
||||||
|
return () => document.removeEventListener('selectionchange', onSelect);
|
||||||
|
}, [disabled, minLength]);
|
||||||
|
|
||||||
|
const fire = () => {
|
||||||
|
if (!pop) return;
|
||||||
|
onExplain({ text: pop.text, rect: pop.rect });
|
||||||
|
// Collapse selection so the popover disappears.
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setPop(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
data-testid="bl-explain-this"
|
||||||
|
data-disabled={disabled ? 'true' : 'false'}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{pop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bl-explain-this-popover"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={fire}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: pop.x,
|
||||||
|
top: pop.y,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
zIndex: 50,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--bl-accent, #6366f1)',
|
||||||
|
background: 'var(--bl-accent, #6366f1)',
|
||||||
|
color: 'var(--bl-on-accent, #fff)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ {ctaLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
packages/ai-ui/src/Markdown.tsx
Normal file
333
packages/ai-ui/src/Markdown.tsx
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import { type CSSProperties, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface MarkdownCitation {
|
||||||
|
/** Citation id token matched in the source text — e.g. `doc:42#§3`. */
|
||||||
|
id: string;
|
||||||
|
/** Display label shown inside the chip. */
|
||||||
|
label: string;
|
||||||
|
/** Optional click handler — typically opens the source document. */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownProps {
|
||||||
|
/** Markdown source string. */
|
||||||
|
source: string;
|
||||||
|
/**
|
||||||
|
* Optional citation registry. Inline `[cite:<id>]` tokens are
|
||||||
|
* replaced with `<CitationChip>`-styled inline chips that look up
|
||||||
|
* the id here. Missing ids render as `[?]` to keep the failure mode
|
||||||
|
* obvious.
|
||||||
|
*/
|
||||||
|
citations?: MarkdownCitation[];
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<Markdown>` — pure-JS, dependency-free subset Markdown renderer with
|
||||||
|
* inline citation support.
|
||||||
|
*
|
||||||
|
* Supports: # / ## / ### headings, **bold**, *italic*, `inline code`,
|
||||||
|
* ```fenced code```, - / 1. lists, [link](url), `[cite:<id>]`.
|
||||||
|
*
|
||||||
|
* Tradeoffs: no GFM tables, no HTML pass-through (intentional for
|
||||||
|
* safety), no nested lists. Hosts that need richer rendering should
|
||||||
|
* compose a markdown pipeline (remark/rehype) and feed the result here
|
||||||
|
* via a custom renderer.
|
||||||
|
*
|
||||||
|
* Wave 9.E.1.
|
||||||
|
*/
|
||||||
|
export function Markdown({
|
||||||
|
source,
|
||||||
|
citations,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: MarkdownProps) {
|
||||||
|
const blocks = parseBlocks(source);
|
||||||
|
const map = new Map<string, MarkdownCitation>(
|
||||||
|
(citations ?? []).map((c) => [c.id, c]),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-markdown"
|
||||||
|
className={className}
|
||||||
|
style={{ fontSize: 14, lineHeight: 1.6, ...style }}
|
||||||
|
>
|
||||||
|
{blocks.map((b, i) => renderBlock(b, i, map))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: 'heading'; level: 1 | 2 | 3; text: string }
|
||||||
|
| { kind: 'paragraph'; text: string }
|
||||||
|
| { kind: 'code'; lang: string; text: string }
|
||||||
|
| { kind: 'list'; ordered: boolean; items: string[] };
|
||||||
|
|
||||||
|
function parseBlocks(source: string): Block[] {
|
||||||
|
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const out: Block[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line === undefined) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Fenced code block.
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
const lang = line.slice(3).trim();
|
||||||
|
const buf: string[] = [];
|
||||||
|
i += 1;
|
||||||
|
while (i < lines.length && !(lines[i] ?? '').startsWith('```')) {
|
||||||
|
buf.push(lines[i] ?? '');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push({ kind: 'code', lang, text: buf.join('\n') });
|
||||||
|
i += 1; // skip closing fence
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Heading.
|
||||||
|
const h = /^(#{1,3})\s+(.+)$/.exec(line);
|
||||||
|
if (h) {
|
||||||
|
const level = h[1]?.length as 1 | 2 | 3;
|
||||||
|
out.push({ kind: 'heading', level, text: h[2] ?? '' });
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// List.
|
||||||
|
if (/^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)) {
|
||||||
|
const ordered = /^\d+\.\s+/.test(line);
|
||||||
|
const items: string[] = [];
|
||||||
|
while (
|
||||||
|
i < lines.length &&
|
||||||
|
((ordered && /^\d+\.\s+/.test(lines[i] ?? '')) ||
|
||||||
|
(!ordered && /^[-*]\s+/.test(lines[i] ?? '')))
|
||||||
|
) {
|
||||||
|
const m = ordered
|
||||||
|
? /^\d+\.\s+(.*)$/.exec(lines[i] ?? '')
|
||||||
|
: /^[-*]\s+(.*)$/.exec(lines[i] ?? '');
|
||||||
|
items.push(m?.[1] ?? '');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push({ kind: 'list', ordered, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Blank line.
|
||||||
|
if (line.trim() === '') {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Paragraph — consume until blank line or block start.
|
||||||
|
const buf: string[] = [];
|
||||||
|
while (
|
||||||
|
i < lines.length &&
|
||||||
|
(lines[i] ?? '').trim() !== '' &&
|
||||||
|
!(lines[i] ?? '').startsWith('```') &&
|
||||||
|
!/^(#{1,3})\s+/.test(lines[i] ?? '') &&
|
||||||
|
!/^[-*]\s+/.test(lines[i] ?? '') &&
|
||||||
|
!/^\d+\.\s+/.test(lines[i] ?? '')
|
||||||
|
) {
|
||||||
|
buf.push(lines[i] ?? '');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push({ kind: 'paragraph', text: buf.join('\n') });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(b: Block, key: number, cites: Map<string, MarkdownCitation>): ReactNode {
|
||||||
|
switch (b.kind) {
|
||||||
|
case 'heading': {
|
||||||
|
const Tag = (`h${b.level}` as 'h1' | 'h2' | 'h3') as 'h1';
|
||||||
|
const sizes = { 1: 22, 2: 18, 3: 16 } as const;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
fontSize: sizes[b.level],
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: '14px 0 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text, cites)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'paragraph':
|
||||||
|
return (
|
||||||
|
<p key={key} style={{ margin: '6px 0' }}>
|
||||||
|
{renderInline(b.text, cites)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'code':
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={key}
|
||||||
|
data-testid="bl-markdown-code"
|
||||||
|
data-lang={b.lang || undefined}
|
||||||
|
style={{
|
||||||
|
margin: '8px 0',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--bl-surface-muted, #f6f6f6)',
|
||||||
|
color: 'var(--bl-text-primary, #111)',
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.text}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
case 'list': {
|
||||||
|
const Tag = b.ordered ? 'ol' : 'ul';
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={key}
|
||||||
|
style={{ margin: '6px 0 6px 20px', paddingLeft: 8 }}
|
||||||
|
>
|
||||||
|
{b.items.map((it, j) => (
|
||||||
|
<li key={j} style={{ margin: '2px 0' }}>
|
||||||
|
{renderInline(it, cites)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline-token renderer. Tokens recognised:
|
||||||
|
* `code` inline code
|
||||||
|
* **bold** bold
|
||||||
|
* *italic* italic
|
||||||
|
* [text](url) link (opens in same tab, target=_self)
|
||||||
|
* [cite:<id>] citation chip (resolved from registry)
|
||||||
|
*
|
||||||
|
* Implementation is a single pass with regex alternation — order
|
||||||
|
* matters (inline code first to protect from formatting consumption).
|
||||||
|
*/
|
||||||
|
function renderInline(text: string, cites: Map<string, MarkdownCitation>): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
// Tokenizer — single regex picks the FIRST matching pattern.
|
||||||
|
const tokenRe =
|
||||||
|
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[cite:[^\]]+\])|(\[[^\]]+\]\([^)]+\))/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let key = 0;
|
||||||
|
while ((m = tokenRe.exec(text))) {
|
||||||
|
if (m.index > last) {
|
||||||
|
out.push(text.slice(last, m.index));
|
||||||
|
}
|
||||||
|
const full = m[0];
|
||||||
|
if (full.startsWith('`')) {
|
||||||
|
out.push(
|
||||||
|
<code
|
||||||
|
key={key++}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bl-surface-muted, #f0f0f0)',
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{full.slice(1, -1)}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
} else if (full.startsWith('**')) {
|
||||||
|
out.push(
|
||||||
|
<strong key={key++}>{full.slice(2, -2)}</strong>,
|
||||||
|
);
|
||||||
|
} else if (full.startsWith('[cite:')) {
|
||||||
|
const id = full.slice('[cite:'.length, -1);
|
||||||
|
const c = cites.get(id);
|
||||||
|
out.push(
|
||||||
|
<CitationChip
|
||||||
|
key={key++}
|
||||||
|
id={id}
|
||||||
|
label={c?.label}
|
||||||
|
onClick={c?.onClick}
|
||||||
|
missing={!c}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else if (full.startsWith('[')) {
|
||||||
|
const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(full);
|
||||||
|
if (lm) {
|
||||||
|
out.push(
|
||||||
|
<a
|
||||||
|
key={key++}
|
||||||
|
href={lm[2]}
|
||||||
|
style={{
|
||||||
|
color: 'var(--bl-accent, #6366f1)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lm[1]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
} else if (full.startsWith('*')) {
|
||||||
|
out.push(<em key={key++}>{full.slice(1, -1)}</em>);
|
||||||
|
}
|
||||||
|
last = m.index + full.length;
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
out.push(text.slice(last));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CitationChip({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
missing,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
missing: boolean;
|
||||||
|
}) {
|
||||||
|
const text = missing ? '[?]' : label ?? id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
data-testid="bl-markdown-citation"
|
||||||
|
data-id={id}
|
||||||
|
data-missing={missing ? 'true' : 'false'}
|
||||||
|
aria-label={`Citation ${id}`}
|
||||||
|
disabled={!onClick}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '0 2px',
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid color-mix(in srgb, var(--bl-accent, #6366f1) 35%, transparent)',
|
||||||
|
background: missing
|
||||||
|
? 'var(--bl-surface-muted, #f0f0f0)'
|
||||||
|
: 'color-mix(in srgb, var(--bl-accent, #6366f1) 14%, transparent)',
|
||||||
|
color: missing
|
||||||
|
? 'var(--bl-text-tertiary, #888)'
|
||||||
|
: 'var(--bl-accent, #4338ca)',
|
||||||
|
font: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
verticalAlign: 'baseline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
packages/ai-ui/src/__tests__/composition.test.tsx
Normal file
217
packages/ai-ui/src/__tests__/composition.test.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { act, cleanup, render, renderHook, screen, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { Markdown } from '../Markdown.js';
|
||||||
|
import { CodeDiff } from '../CodeDiff.js';
|
||||||
|
import { ExplainThis } from '../ExplainThis.js';
|
||||||
|
import { usePromptHistory } from '../usePromptHistory.js';
|
||||||
|
import { useTokenCount } from '../useTokenCount.js';
|
||||||
|
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('Markdown', () => {
|
||||||
|
it('renders headings + paragraphs + bold + italic + inline code', () => {
|
||||||
|
render(
|
||||||
|
<Markdown
|
||||||
|
source={[
|
||||||
|
'# Title',
|
||||||
|
'',
|
||||||
|
'A **bold** word, *italic* word, and `inline` code.',
|
||||||
|
'',
|
||||||
|
'## Sub',
|
||||||
|
'',
|
||||||
|
'Closing line.',
|
||||||
|
].join('\n')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const root = screen.getByTestId('bl-markdown');
|
||||||
|
expect(root.querySelectorAll('h1')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('h2')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('strong')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('em')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('code')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fenced code blocks with lang attribute', () => {
|
||||||
|
render(
|
||||||
|
<Markdown
|
||||||
|
source={['```ts', 'const x = 1;', '```'].join('\n')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const pre = screen.getByTestId('bl-markdown-code');
|
||||||
|
expect(pre.getAttribute('data-lang')).toBe('ts');
|
||||||
|
expect(pre.textContent).toMatch(/const x = 1;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unordered and ordered lists', () => {
|
||||||
|
render(
|
||||||
|
<Markdown
|
||||||
|
source={['- one', '- two', '', '1. first', '2. second'].join('\n')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const root = screen.getByTestId('bl-markdown');
|
||||||
|
expect(root.querySelectorAll('ul')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('ol')).toHaveLength(1);
|
||||||
|
expect(root.querySelectorAll('li')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders citation chips and marks missing ids', () => {
|
||||||
|
render(
|
||||||
|
<Markdown
|
||||||
|
source="See [cite:doc:42#§3] and [cite:doc:missing]."
|
||||||
|
citations={[{ id: 'doc:42#§3', label: 'Q4 review' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const chips = screen.getAllByTestId('bl-markdown-citation');
|
||||||
|
expect(chips).toHaveLength(2);
|
||||||
|
expect(chips[0]!.getAttribute('data-missing')).toBe('false');
|
||||||
|
expect(chips[0]!.textContent).toBe('Q4 review');
|
||||||
|
expect(chips[1]!.getAttribute('data-missing')).toBe('true');
|
||||||
|
expect(chips[1]!.textContent).toBe('[?]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders links via [text](url) syntax', () => {
|
||||||
|
render(<Markdown source="Read [the docs](https://example.com/docs)." />);
|
||||||
|
const a = screen.getByTestId('bl-markdown').querySelector('a');
|
||||||
|
expect(a?.getAttribute('href')).toBe('https://example.com/docs');
|
||||||
|
expect(a?.textContent).toBe('the docs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CodeDiff', () => {
|
||||||
|
const before = ['const x = 1;', 'const y = 2;', 'console.log(x + y);'].join('\n');
|
||||||
|
const after = ['const x = 1;', 'const y = 3;', 'console.log(x * y);'].join('\n');
|
||||||
|
|
||||||
|
it('renders split view by default with both panes', () => {
|
||||||
|
render(<CodeDiff before={before} after={after} filename="demo.ts" />);
|
||||||
|
const el = screen.getByTestId('bl-code-diff');
|
||||||
|
expect(el.getAttribute('data-view')).toBe('split');
|
||||||
|
expect(screen.getByTestId('bl-code-diff-left')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-code-diff-right')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects deletion + addition rows in the diff', () => {
|
||||||
|
render(<CodeDiff before={before} after={after} view="unified" />);
|
||||||
|
const rows = screen.getAllByTestId('bl-code-diff-row');
|
||||||
|
expect(rows.some((r) => r.getAttribute('data-kind') === 'add')).toBe(true);
|
||||||
|
expect(rows.some((r) => r.getAttribute('data-kind') === 'del')).toBe(true);
|
||||||
|
expect(rows.some((r) => r.getAttribute('data-kind') === 'eq')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unified view renders all kinds inline', () => {
|
||||||
|
render(<CodeDiff before="a\nb" after="a\nc" view="unified" />);
|
||||||
|
expect(screen.getByTestId('bl-code-diff-unified')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identical inputs produce only `eq` rows', () => {
|
||||||
|
render(<CodeDiff before="x\ny" after="x\ny" view="unified" />);
|
||||||
|
const kinds = screen
|
||||||
|
.getAllByTestId('bl-code-diff-row')
|
||||||
|
.map((r) => r.getAttribute('data-kind'));
|
||||||
|
expect(kinds.every((k) => k === 'eq')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExplainThis', () => {
|
||||||
|
it('renders children and stays hidden when selection is empty', () => {
|
||||||
|
render(
|
||||||
|
<ExplainThis onExplain={() => {}}>
|
||||||
|
<p data-testid="text">Hello there.</p>
|
||||||
|
</ExplainThis>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId('bl-explain-this-popover')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled flag suppresses the popover even on selection', () => {
|
||||||
|
render(
|
||||||
|
<ExplainThis disabled onExplain={() => {}}>
|
||||||
|
<p data-testid="text">Hello there.</p>
|
||||||
|
</ExplainThis>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('bl-explain-this').getAttribute('data-disabled'),
|
||||||
|
).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usePromptHistory', () => {
|
||||||
|
it('pushes + dedupes consecutive duplicates', () => {
|
||||||
|
const { result } = renderHook(() => usePromptHistory({ storageKey: null }));
|
||||||
|
act(() => result.current.push('hello'));
|
||||||
|
act(() => result.current.push('hello'));
|
||||||
|
act(() => result.current.push('world'));
|
||||||
|
expect(result.current.history).toEqual(['hello', 'world']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims to capacity', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePromptHistory({ storageKey: null, capacity: 3 }),
|
||||||
|
);
|
||||||
|
act(() => result.current.push('a'));
|
||||||
|
act(() => result.current.push('b'));
|
||||||
|
act(() => result.current.push('c'));
|
||||||
|
act(() => result.current.push('d'));
|
||||||
|
expect(result.current.history).toEqual(['b', 'c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev/next walks the history in bash-style order', () => {
|
||||||
|
const { result } = renderHook(() => usePromptHistory({ storageKey: null }));
|
||||||
|
act(() => result.current.push('a'));
|
||||||
|
act(() => result.current.push('b'));
|
||||||
|
act(() => result.current.push('c'));
|
||||||
|
let v: string | null = null;
|
||||||
|
act(() => {
|
||||||
|
v = result.current.prev();
|
||||||
|
});
|
||||||
|
expect(v).toBe('c');
|
||||||
|
act(() => {
|
||||||
|
v = result.current.prev();
|
||||||
|
});
|
||||||
|
expect(v).toBe('b');
|
||||||
|
act(() => {
|
||||||
|
v = result.current.next();
|
||||||
|
});
|
||||||
|
expect(v).toBe('c');
|
||||||
|
act(() => {
|
||||||
|
v = result.current.next();
|
||||||
|
});
|
||||||
|
expect(v).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear empties the history', () => {
|
||||||
|
const { result } = renderHook(() => usePromptHistory({ storageKey: null }));
|
||||||
|
act(() => result.current.push('a'));
|
||||||
|
act(() => result.current.clear());
|
||||||
|
expect(result.current.history).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTokenCount', () => {
|
||||||
|
it('approximates tokens at 4 chars/token by default', () => {
|
||||||
|
const { result } = renderHook(() => useTokenCount('hello there'));
|
||||||
|
expect(result.current.chars).toBe(11);
|
||||||
|
expect(result.current.tokens).toBe(3); // ceil(11/4)
|
||||||
|
expect(result.current.costUsd).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes cost when costPer1kUsd is provided', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTokenCount('aaaa bbbb cccc dddd', { costPer1kUsd: 1 }),
|
||||||
|
);
|
||||||
|
expect(result.current.tokens).toBe(5); // ceil(19/4)
|
||||||
|
expect(result.current.costUsd).toBeCloseTo(0.005, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom charsPerToken (3.5 for code-heavy)', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTokenCount('const x = 1;', { charsPerToken: 3.5 }),
|
||||||
|
);
|
||||||
|
expect(result.current.tokens).toBe(Math.ceil(12 / 3.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero for empty input', () => {
|
||||||
|
const { result } = renderHook(() => useTokenCount(''));
|
||||||
|
expect(result.current.tokens).toBe(0);
|
||||||
|
expect(result.current.costUsd).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -76,6 +76,28 @@ export type {
|
|||||||
DebugOverlayProps,
|
DebugOverlayProps,
|
||||||
} from './DebugOverlay.js';
|
} from './DebugOverlay.js';
|
||||||
|
|
||||||
|
// ── 0.6 surfaces — Wave 9.E composition primitives ────────────────────────
|
||||||
|
export { Markdown } from './Markdown.js';
|
||||||
|
export type { MarkdownProps, MarkdownCitation } from './Markdown.js';
|
||||||
|
|
||||||
|
export { CodeDiff } from './CodeDiff.js';
|
||||||
|
export type { CodeDiffProps } from './CodeDiff.js';
|
||||||
|
|
||||||
|
export { ExplainThis } from './ExplainThis.js';
|
||||||
|
export type { ExplainThisProps } from './ExplainThis.js';
|
||||||
|
|
||||||
|
export { usePromptHistory } from './usePromptHistory.js';
|
||||||
|
export type {
|
||||||
|
UsePromptHistoryHelpers,
|
||||||
|
UsePromptHistoryOptions,
|
||||||
|
} from './usePromptHistory.js';
|
||||||
|
|
||||||
|
export { useTokenCount } from './useTokenCount.js';
|
||||||
|
export type {
|
||||||
|
UseTokenCountOptions,
|
||||||
|
UseTokenCountValue,
|
||||||
|
} from './useTokenCount.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ChatTransportOptions,
|
ChatTransportOptions,
|
||||||
Citation,
|
Citation,
|
||||||
|
|||||||
108
packages/ai-ui/src/usePromptHistory.ts
Normal file
108
packages/ai-ui/src/usePromptHistory.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface UsePromptHistoryOptions {
|
||||||
|
/**
|
||||||
|
* Storage key — should be product-scoped (e.g. `notes:chat:history`).
|
||||||
|
* Pass `null` to disable persistence (in-memory only).
|
||||||
|
*/
|
||||||
|
storageKey?: string | null;
|
||||||
|
/** Maximum number of prompts retained. Default 50. */
|
||||||
|
capacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePromptHistoryHelpers {
|
||||||
|
/** The full history array, newest LAST. */
|
||||||
|
history: string[];
|
||||||
|
/** Append a prompt — dedupes consecutive duplicates + trims to capacity. */
|
||||||
|
push: (prompt: string) => void;
|
||||||
|
/** Clear all stored prompts. */
|
||||||
|
clear: () => void;
|
||||||
|
/** Cursor-up — returns the previous prompt (or null at the top). */
|
||||||
|
prev: () => string | null;
|
||||||
|
/** Cursor-down — returns the next prompt (or null at the bottom). */
|
||||||
|
next: () => string | null;
|
||||||
|
/** Reset the cursor — call when the user starts typing fresh. */
|
||||||
|
resetCursor: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `usePromptHistory` — bash-style ↑/↓ recall for prompt composers.
|
||||||
|
*
|
||||||
|
* Wave 9.E.4. Persistence is `localStorage` by default, scoped via
|
||||||
|
* `storageKey`. Pass `null` for in-memory only (useful for tests +
|
||||||
|
* SSR-prerender safety).
|
||||||
|
*/
|
||||||
|
export function usePromptHistory(
|
||||||
|
options: UsePromptHistoryOptions = {},
|
||||||
|
): UsePromptHistoryHelpers {
|
||||||
|
const { storageKey = 'bytelyst:prompt-history', capacity = 50 } = options;
|
||||||
|
|
||||||
|
// Lazy initial value — never touches `localStorage` during SSR.
|
||||||
|
const [history, setHistory] = useState<string[]>(() => {
|
||||||
|
if (!storageKey || typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Cursor: -1 means 'not navigating'. >=0 indexes from the END (so 0
|
||||||
|
// is the newest, history.length-1 is the oldest). This matches the
|
||||||
|
// bash ↑ recall convention.
|
||||||
|
const cursorRef = useRef(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey || typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(history));
|
||||||
|
} catch {
|
||||||
|
/* quota / disabled — ignore silently */
|
||||||
|
}
|
||||||
|
}, [storageKey, history]);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(prompt: string) => {
|
||||||
|
const trimmed = prompt.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setHistory((cur) => {
|
||||||
|
// De-dupe consecutive duplicate.
|
||||||
|
if (cur[cur.length - 1] === trimmed) return cur;
|
||||||
|
const next = [...cur, trimmed];
|
||||||
|
return next.length > capacity ? next.slice(next.length - capacity) : next;
|
||||||
|
});
|
||||||
|
cursorRef.current = -1;
|
||||||
|
},
|
||||||
|
[capacity],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setHistory([]);
|
||||||
|
cursorRef.current = -1;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prev = useCallback((): string | null => {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
const newIdx = Math.min(history.length - 1, cursorRef.current + 1);
|
||||||
|
cursorRef.current = newIdx;
|
||||||
|
return history[history.length - 1 - newIdx] ?? null;
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const next = useCallback((): string | null => {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
if (cursorRef.current <= 0) {
|
||||||
|
cursorRef.current = -1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cursorRef.current -= 1;
|
||||||
|
return history[history.length - 1 - cursorRef.current] ?? null;
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const resetCursor = useCallback(() => {
|
||||||
|
cursorRef.current = -1;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { history, push, clear, prev, next, resetCursor };
|
||||||
|
}
|
||||||
49
packages/ai-ui/src/useTokenCount.ts
Normal file
49
packages/ai-ui/src/useTokenCount.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export interface UseTokenCountOptions {
|
||||||
|
/**
|
||||||
|
* Per-1k-token USD cost (input price). Default 0 (no cost computed).
|
||||||
|
* Override per-model, e.g. `0.00015` for gpt-4o-mini input.
|
||||||
|
*/
|
||||||
|
costPer1kUsd?: number;
|
||||||
|
/**
|
||||||
|
* Chars-per-token approximation. Default 4 — the OpenAI rule-of-thumb
|
||||||
|
* for English. Pass 3.5 for code-heavy contexts, ~6 for tightly
|
||||||
|
* tokenised Indic / CJK scripts.
|
||||||
|
*/
|
||||||
|
charsPerToken?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTokenCountValue {
|
||||||
|
/** Approximate token count (rounded up). */
|
||||||
|
tokens: number;
|
||||||
|
/** Character count (raw length of `text`). */
|
||||||
|
chars: number;
|
||||||
|
/** Approximate USD cost. 0 if `costPer1kUsd` is 0 / undefined. */
|
||||||
|
costUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useTokenCount` — cheap, dependency-free token estimator for live
|
||||||
|
* input previews.
|
||||||
|
*
|
||||||
|
* Wave 9.E.5. Not for billing. Hosts that need accurate counts should
|
||||||
|
* call the model provider's tokenizer; this hook is for UI feedback in
|
||||||
|
* composer surfaces ("you're typing ~412 tokens, ≈ \$0.0001").
|
||||||
|
*
|
||||||
|
* Pure function under the hood — wrapped in `useMemo` so the result
|
||||||
|
* is stable across renders when `text` is unchanged.
|
||||||
|
*/
|
||||||
|
export function useTokenCount(
|
||||||
|
text: string,
|
||||||
|
options: UseTokenCountOptions = {},
|
||||||
|
): UseTokenCountValue {
|
||||||
|
const { costPer1kUsd = 0, charsPerToken = 4 } = options;
|
||||||
|
return useMemo(() => {
|
||||||
|
const chars = text.length;
|
||||||
|
const cpt = charsPerToken > 0 ? charsPerToken : 4;
|
||||||
|
const tokens = chars === 0 ? 0 : Math.ceil(chars / cpt);
|
||||||
|
const costUsd = costPer1kUsd > 0 ? (tokens / 1000) * costPer1kUsd : 0;
|
||||||
|
return { tokens, chars, costUsd };
|
||||||
|
}, [text, costPer1kUsd, charsPerToken]);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bytelyst/motion",
|
"name": "@bytelyst/motion",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Motion primitives — Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress, Spotlight, Magnetic, MeshBackground. Honors prefers-reduced-motion.",
|
"description": "Motion primitives — Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress, Spotlight, Magnetic, MeshBackground. Honors prefers-reduced-motion.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
96
packages/motion/src/Parallax.tsx
Normal file
96
packages/motion/src/Parallax.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { prefersReducedMotion } from './utils.js';
|
||||||
|
|
||||||
|
export interface ParallaxProps {
|
||||||
|
/** Slot moved on scroll. */
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Translation speed multiplier. 0 = stationary, 1 = scrolls with the
|
||||||
|
* page (default), 0.5 = scrolls half-speed, -0.5 = scrolls upward.
|
||||||
|
* Sensible range: −1 to 1.
|
||||||
|
*/
|
||||||
|
speed?: number;
|
||||||
|
/** Translation axis. Default `'y'`. */
|
||||||
|
axis?: 'y' | 'x';
|
||||||
|
/** Bypass entirely — useful for tests / reduced-motion. */
|
||||||
|
disableMotion?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<Parallax>` — scroll-driven translation wrapper.
|
||||||
|
*
|
||||||
|
* Uses `transform: translate3d(…)` updated inside a `requestAnimationFrame`
|
||||||
|
* loop driven by `window.scroll`. The transform is computed relative to
|
||||||
|
* the element's vertical centre crossing the viewport's centre — so the
|
||||||
|
* effect peaks while the element is on-screen and never accumulates
|
||||||
|
* unbounded translation.
|
||||||
|
*
|
||||||
|
* Honours `prefers-reduced-motion`: with `disableMotion` (or the OS
|
||||||
|
* setting) the wrapper renders un-transformed.
|
||||||
|
*
|
||||||
|
* Wave 13.D.1. Zero deps, ~1.4 KB gzipped. Pairs with `<TiltCard>`
|
||||||
|
* and `<TiltGallery>` for the spatial-hero pattern.
|
||||||
|
*/
|
||||||
|
export function Parallax({
|
||||||
|
children,
|
||||||
|
speed = 0.3,
|
||||||
|
axis = 'y',
|
||||||
|
disableMotion,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: ParallaxProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const reduced = disableMotion ?? prefersReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
let raf = 0;
|
||||||
|
const apply = () => {
|
||||||
|
raf = 0;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vh = window.innerHeight || 1;
|
||||||
|
// Distance, in px, of the element's centre from viewport centre.
|
||||||
|
const delta = rect.top + rect.height / 2 - vh / 2;
|
||||||
|
const t = -delta * speed;
|
||||||
|
el.style.transform =
|
||||||
|
axis === 'y'
|
||||||
|
? `translate3d(0, ${t.toFixed(2)}px, 0)`
|
||||||
|
: `translate3d(${t.toFixed(2)}px, 0, 0)`;
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (raf) return;
|
||||||
|
raf = window.requestAnimationFrame(apply);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
if (raf) window.cancelAnimationFrame(raf);
|
||||||
|
el.style.transform = '';
|
||||||
|
};
|
||||||
|
}, [reduced, speed, axis]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-testid="bl-parallax"
|
||||||
|
data-reduced={reduced ? 'true' : 'false'}
|
||||||
|
data-axis={axis}
|
||||||
|
className={className}
|
||||||
|
style={{ willChange: reduced ? undefined : 'transform', ...style }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
packages/motion/src/TiltGallery.tsx
Normal file
218
packages/motion/src/TiltGallery.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -241,3 +241,50 @@ describe('MeshBackground', () => {
|
|||||||
expect(screen.getByTestId('bl-mesh-background')).toBeDefined();
|
expect(screen.getByTestId('bl-mesh-background')).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Wave 13.D.1 / .5 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { Parallax } from '../Parallax.js';
|
||||||
|
import { TiltGallery, type TiltGalleryItem } from '../TiltGallery.js';
|
||||||
|
|
||||||
|
describe('Parallax', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
it('records axis + reduced data attributes', () => {
|
||||||
|
render(
|
||||||
|
<Parallax axis="x" disableMotion>
|
||||||
|
<span data-testid="px-child" />
|
||||||
|
</Parallax>,
|
||||||
|
);
|
||||||
|
const el = screen.getByTestId('bl-parallax');
|
||||||
|
expect(el.getAttribute('data-axis')).toBe('x');
|
||||||
|
expect(el.getAttribute('data-reduced')).toBe('true');
|
||||||
|
});
|
||||||
|
it('renders children + does not throw on scroll', () => {
|
||||||
|
render(<Parallax><span data-testid="px-child2" /></Parallax>);
|
||||||
|
// No setState in the listener; just confirm the listener is attached + safe.
|
||||||
|
window.dispatchEvent(new Event('scroll'));
|
||||||
|
expect(screen.getByTestId('px-child2')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const TILT_ITEMS: TiltGalleryItem[] = [
|
||||||
|
{ id: 'tg-1', content: <span data-testid="tg-c1">A</span>, caption: 'one' },
|
||||||
|
{ id: 'tg-2', content: <span data-testid="tg-c2">B</span> },
|
||||||
|
{ id: 'tg-3', content: <span data-testid="tg-c3">C</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TiltGallery', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
it('renders one tile per item', () => {
|
||||||
|
render(<TiltGallery items={TILT_ITEMS} disableMotion />);
|
||||||
|
expect(screen.getAllByTestId('bl-tilt-gallery-tile')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
it('records reduced data attribute on the region', () => {
|
||||||
|
render(<TiltGallery items={TILT_ITEMS} disableMotion />);
|
||||||
|
expect(screen.getByTestId('bl-tilt-gallery').getAttribute('data-reduced')).toBe('true');
|
||||||
|
});
|
||||||
|
it('uses aria-label for the region', () => {
|
||||||
|
render(<TiltGallery items={TILT_ITEMS} ariaLabel="Demo gallery" disableMotion />);
|
||||||
|
expect(screen.getByTestId('bl-tilt-gallery').getAttribute('aria-label')).toBe('Demo gallery');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -48,5 +48,11 @@ export type { MagneticProps } from './Magnetic.js';
|
|||||||
export { MeshBackground } from './MeshBackground.js';
|
export { MeshBackground } from './MeshBackground.js';
|
||||||
export type { MeshBackgroundProps } from './MeshBackground.js';
|
export type { MeshBackgroundProps } from './MeshBackground.js';
|
||||||
|
|
||||||
|
export { Parallax } from './Parallax.js';
|
||||||
|
export type { ParallaxProps } from './Parallax.js';
|
||||||
|
|
||||||
|
export { TiltGallery } from './TiltGallery.js';
|
||||||
|
export type { TiltGalleryItem, TiltGalleryProps } from './TiltGallery.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