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:
saravanakumardb1 2026-05-27 16:59:28 -07:00
parent ec9e11b243
commit 87e3bc490a
13 changed files with 1455 additions and 2 deletions

View File

@ -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": {

View 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;
}

View 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>
);
}

View 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>
);
}

View 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);
});
});

View File

@ -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,

View 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 };
}

View 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]);
}

View File

@ -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": {

View 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>
);
}

View 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>
);
}

View File

@ -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');
});
});

View File

@ -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';