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