──────────────────────────────────────────────────────────────────
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.
225 lines
5.9 KiB
TypeScript
225 lines
5.9 KiB
TypeScript
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;
|
||
}
|