diff --git a/packages/ai-ui/package.json b/packages/ai-ui/package.json index 3e779d09..39854a62 100644 --- a/packages/ai-ui/package.json +++ b/packages/ai-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/ai-ui", - "version": "0.5.0", + "version": "0.6.0", "type": "module", "description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.", "exports": { diff --git a/packages/ai-ui/src/CodeDiff.tsx b/packages/ai-ui/src/CodeDiff.tsx new file mode 100644 index 00000000..567fa1ee --- /dev/null +++ b/packages/ai-ui/src/CodeDiff.tsx @@ -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 }; + +/** + * `` — 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 `` 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 ( +
+
+ {filename ?? 'diff'} + + {language ? `${language} · ` : ''} + {view} + +
+ {view === 'split' ? ( + + ) : ( + + )} +
+ ); +} + +function SplitView({ ops }: { ops: DiffOp[] }) { + return ( +
+
+ {ops.map((op, i) => { + if (op.kind === 'add') return null; + return ( + + ); + })} +
+
+ {ops.map((op, i) => { + if (op.kind === 'del') return null; + return ( + + ); + })} +
+
+ ); +} + +function UnifiedView({ ops }: { ops: DiffOp[] }) { + return ( +
+ {ops.map((op, i) => ( + + ))} +
+ ); +} + +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 ( +
+ + {text} +
+ ); +} + +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 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(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; +} diff --git a/packages/ai-ui/src/ExplainThis.tsx b/packages/ai-ui/src/ExplainThis.tsx new file mode 100644 index 00000000..13d8bd86 --- /dev/null +++ b/packages/ai-ui/src/ExplainThis.tsx @@ -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; +} + +/** + * `` — 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(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 ( +
+ {children} + {pop && ( + + )} +
+ ); +} diff --git a/packages/ai-ui/src/Markdown.tsx b/packages/ai-ui/src/Markdown.tsx new file mode 100644 index 00000000..b1922e51 --- /dev/null +++ b/packages/ai-ui/src/Markdown.tsx @@ -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:]` tokens are + * replaced with ``-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; +} + +/** + * `` — pure-JS, dependency-free subset Markdown renderer with + * inline citation support. + * + * Supports: # / ## / ### headings, **bold**, *italic*, `inline code`, + * ```fenced code```, - / 1. lists, [link](url), `[cite:]`. + * + * 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( + (citations ?? []).map((c) => [c.id, c]), + ); + return ( +
+ {blocks.map((b, i) => renderBlock(b, i, map))} +
+ ); +} + +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): 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 ( + + {renderInline(b.text, cites)} + + ); + } + case 'paragraph': + return ( +

+ {renderInline(b.text, cites)} +

+ ); + case 'code': + return ( +
+          {b.text}
+        
+ ); + case 'list': { + const Tag = b.ordered ? 'ol' : 'ul'; + return ( + + {b.items.map((it, j) => ( +
  • + {renderInline(it, cites)} +
  • + ))} +
    + ); + } + 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:] 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): 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( + + {full.slice(1, -1)} + , + ); + } else if (full.startsWith('**')) { + out.push( + {full.slice(2, -2)}, + ); + } else if (full.startsWith('[cite:')) { + const id = full.slice('[cite:'.length, -1); + const c = cites.get(id); + out.push( + , + ); + } else if (full.startsWith('[')) { + const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(full); + if (lm) { + out.push( + + {lm[1]} + , + ); + } else { + out.push(full); + } + } else if (full.startsWith('*')) { + out.push({full.slice(1, -1)}); + } + 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 ( + + ); +} diff --git a/packages/ai-ui/src/__tests__/composition.test.tsx b/packages/ai-ui/src/__tests__/composition.test.tsx new file mode 100644 index 00000000..2af48fb3 --- /dev/null +++ b/packages/ai-ui/src/__tests__/composition.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId('bl-code-diff-unified')).toBeDefined(); + }); + + it('identical inputs produce only `eq` rows', () => { + render(); + 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( + {}}> +

    Hello there.

    +
    , + ); + expect(screen.queryByTestId('bl-explain-this-popover')).toBeNull(); + }); + + it('disabled flag suppresses the popover even on selection', () => { + render( + {}}> +

    Hello there.

    +
    , + ); + 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); + }); +}); diff --git a/packages/ai-ui/src/index.ts b/packages/ai-ui/src/index.ts index 6ea4dfaf..4c0a6d3a 100644 --- a/packages/ai-ui/src/index.ts +++ b/packages/ai-ui/src/index.ts @@ -76,6 +76,28 @@ export type { DebugOverlayProps, } 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 { ChatTransportOptions, Citation, diff --git a/packages/ai-ui/src/usePromptHistory.ts b/packages/ai-ui/src/usePromptHistory.ts new file mode 100644 index 00000000..c2bd35d3 --- /dev/null +++ b/packages/ai-ui/src/usePromptHistory.ts @@ -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(() => { + 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 }; +} diff --git a/packages/ai-ui/src/useTokenCount.ts b/packages/ai-ui/src/useTokenCount.ts new file mode 100644 index 00000000..3e682b3c --- /dev/null +++ b/packages/ai-ui/src/useTokenCount.ts @@ -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]); +} diff --git a/packages/motion/package.json b/packages/motion/package.json index 3cae88ae..e3bb6fd2 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/motion", - "version": "0.2.0", + "version": "0.2.1", "type": "module", "description": "Motion primitives — Reveal, StaggerList, NumberFlow, TiltCard, ScrollProgress, Spotlight, Magnetic, MeshBackground. Honors prefers-reduced-motion.", "exports": { diff --git a/packages/motion/src/Parallax.tsx b/packages/motion/src/Parallax.tsx new file mode 100644 index 00000000..d2309e75 --- /dev/null +++ b/packages/motion/src/Parallax.tsx @@ -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; +} + +/** + * `` — 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 `` + * and `` for the spatial-hero pattern. + */ +export function Parallax({ + children, + speed = 0.3, + axis = 'y', + disableMotion, + className, + style, +}: ParallaxProps) { + const ref = useRef(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 ( +
    + {children} +
    + ); +} diff --git a/packages/motion/src/TiltGallery.tsx b/packages/motion/src/TiltGallery.tsx new file mode 100644 index 00000000..04bbbfa7 --- /dev/null +++ b/packages/motion/src/TiltGallery.tsx @@ -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; +} + +/** + * `` — horizontally-scrolling gallery of ``-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 `` — 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(null); + const baseId = useId(); + + const onArrow = useCallback( + (e: ReactKeyboardEvent) => { + 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 ( +
    + {items.map((item, i) => ( + + ))} +
    + ); +} + +function Tile({ + id, + item, + maxTilt, + width, + height, + reduced, +}: { + id: string; + item: TiltGalleryItem; + maxTilt: number; + width: number; + height: number; + reduced: boolean; +}) { + const ref = useRef(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 ( +
    +
    +
    + {item.content} +
    + {!reduced && ( + + {item.caption && ( +
    + {item.caption} +
    + )} +
    + ); +} diff --git a/packages/motion/src/__tests__/motion.test.tsx b/packages/motion/src/__tests__/motion.test.tsx index ee562483..2a1f3ba3 100644 --- a/packages/motion/src/__tests__/motion.test.tsx +++ b/packages/motion/src/__tests__/motion.test.tsx @@ -241,3 +241,50 @@ describe('MeshBackground', () => { 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( + + + , + ); + 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(); + // 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: A, caption: 'one' }, + { id: 'tg-2', content: B }, + { id: 'tg-3', content: C }, +]; + +describe('TiltGallery', () => { + beforeEach(() => cleanup()); + it('renders one tile per item', () => { + render(); + expect(screen.getAllByTestId('bl-tilt-gallery-tile')).toHaveLength(3); + }); + it('records reduced data attribute on the region', () => { + render(); + expect(screen.getByTestId('bl-tilt-gallery').getAttribute('data-reduced')).toBe('true'); + }); + it('uses aria-label for the region', () => { + render(); + expect(screen.getByTestId('bl-tilt-gallery').getAttribute('aria-label')).toBe('Demo gallery'); + }); +}); diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts index 608db59a..051dc20a 100644 --- a/packages/motion/src/index.ts +++ b/packages/motion/src/index.ts @@ -48,5 +48,11 @@ export type { MagneticProps } from './Magnetic.js'; export { MeshBackground } 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 type { Spring } from './utils.js';