AI output
Diff review
A list of changes an AI suggests, word by word for prose or line by line for code, where accepting or declining resolves the text in place as the parts that do not survive shrink out and the pressed button becomes the answer while the header count rolls.
Live preview
Loading preview
Three proposed edits to a short product description (two word changes and one sentence rewrite) and, in a second demo, two line changes to a config file; accept one, decline one and use Accept all for the rest. Replay resets every decision to pending.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add diff-reviewUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type Change, type Decisions, DiffReview } from './diff-review';
const WORDS: Change[] = [
{
id: 'description',
label: 'Product description',
before: 'A light jacket for cold mornings. It packs into its own pocket and weighs very little.',
after: 'A warm, light jacket for chilly mornings. It packs into its own pocket and weighs 240 g.',
},
{
id: 'care',
label: 'Care note',
before: 'Wash cold and hang to dry.',
after: 'Machine wash cold and hang to dry in the shade.',
},
{
id: 'closing',
label: 'Closing line',
before: 'Made to be taken everywhere.',
after: 'Built to go with you on every trip, near or far.',
},
];
const LINES: Change[] = [
{
id: 'config',
label: 'config.json',
before: '{\n "lang": "en",\n "theme": "light",\n "fontSize": 14\n}',
after: '{\n "lang": "en",\n "theme": "system",\n "fontSize": 14\n}',
},
{
id: 'motion',
label: 'motion.json',
before: '{\n "speed": "slow",\n "loop": true\n}',
after: '{\n "speed": "calm",\n "loop": true,\n "pause": 2\n}',
},
];
// Replay accepts the first change, declines the second and uses Accept all for the rest.
const SCRIPT: [at: number, run: (now: Decisions) => Decisions][] = [
[1200, (now) => ({ ...now, description: 'accepted' })],
[2400, (now) => ({ ...now, care: 'declined' })],
];
export default function DiffReviewDemo({ replay = 0 }: { replay?: number }) {
const [words, setWords] = React.useState<Decisions>({});
const [lines, setLines] = React.useState<Decisions>({});
const root = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0) return;
setWords({});
setLines({});
const timers = SCRIPT.map(([at, run]) => window.setTimeout(() => setWords(run), at));
timers.push(
window.setTimeout(
() => root.current?.querySelector<HTMLElement>('[data-slot="accept-all"]')?.click(),
3600,
),
);
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div ref={root} className="flex w-full max-w-[560px] flex-col gap-10">
<DiffReview changes={WORDS} decisions={words} onDecisionsChange={setWords} />
<DiffReview changes={LINES} mode="lines" decisions={lines} onDecisionsChange={setLines} />
</div>
);
}
API
Parts: root, header: title, count (rolls), decline all and accept all buttons, change list, change card: header with label, + and - counts, decline button, accept button (drawn check), undo link, word diff (prose): removed words struck through, added words underlined, line diff (code): lines with a + or - gutter, live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| changes | Array<{ id: string; before: string; after: string; label?: string }> | [] | The suggested changes. label says where the change is, for example "Paragraph 2" or a file name. |
| mode | "words" | "lines" | "words" | Compare word by word (prose) or line by line (code). |
| decisions | Record<string, "pending" | "accepted" | "declined"> | undefined | Controlled decisions by change id; missing ids are pending. |
| defaultDecisions | Record<string, "pending" | "accepted" | "declined"> | {} | Decisions at first when not controlled. |
| readOnly | boolean | false | Shows the decisions without buttons or Undo, for example after they were sent. |
| title | string | "Proposed changes" | Header title. |
| Event | Description |
|---|---|
| decisionsChange | Decisions changed (one call for Accept all or Decline all). Payload: every decision by id. |
| decisionChange | One change got a decision or was undone. Payload: { id, decision }. |
| complete | The last pending change got a decision. Payload: every decision by id. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| DRV1 | A decision is made or undone | header count (pending, then accepted and total) | number roll | old count → new count | duration-5xl (640)ease-out | Swaps at once |
| DRV2 | The last pending change gets a decision | header count words | text morph | left to review → accepted | duration-3xl (460)ease-out | Swaps at once |
| DRV3 | A piece shows or hides | "of N" in the count, decline all and accept all, card decline and accept buttons, undo | width (grid columns) | 0fr → 1fr | duration-2xl (420)ease-out | Resizes at once |
| DRV4 | A piece shows | "of N" in the count, decline all and accept all, card decline and accept buttons, undo | opacity, blur | 0, blur-swap → 1, 0 | duration-lg (320)ease-out | Shows at once |
| DRV5 | A piece hides | "of N" in the count, decline all and accept all, card decline and accept buttons, undo | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| DRV6 | Accept or decline | pressed button label | text morph | Accept, Decline → Accepted, Declined | duration-3xl (460)ease-out | Swaps at once |
| DRV7 | Accept | plain check | opacity | 1 → 0 | duration-sm (200)ease-out | Hides at once |
| DRV8 | Accept | drawn check | stroke drawn | 0% → 100% | duration-2xl (420)ease-outdelay 120ms | Shows at once |
| DRV9 | Accept (removed words) or decline (added words) | leaving words | font size, margin (the line closes up) | inherit, spacing-xxs → 0, 0 | duration-3xl (460)ease-out | Resizes at once |
| DRV10 | Accept or decline | leaving words | opacity | 1 → 0 | duration-sm (200)ease-out | Hides at once |
| DRV11 | Accept or decline | staying words | background color, text color, line color | bg-success-primary or bg-error-primary, text-success-primary or text-error-primary → transparent, inherit | duration-lg (320)ease-out | Swaps at once |
| DRV12 | Accept (removed lines) or decline (added lines) | leaving lines | height (grid rows) | line height → 0 | duration-2xl (420)ease-out | Resizes at once |
| DRV13 | Accept or decline | leaving lines | opacity | 1 → 0 | duration-sm (200)ease-out | Hides at once |
| DRV14 | Accept or decline | staying lines | background color, text color, gutter opacity | tint, 1 → transparent, 0 | duration-lg (320)ease-out | Swaps at once |
| DRV15 | Decline, or undo a decline | change text | text color | text-primary → text-tertiary | duration-lg (320)ease-out | Swaps at once |
| DRV16 | A decision is made or undone | change card | border color | border-secondary → border-tertiary | duration-lg (320)ease-out | Swaps at once |
| DRV17 | Hover | decline, accept and all buttons | background color, text color | transparent, text-tertiary → bg-primary_hover, text-primary | duration-xs (160)ease-out | Swaps at once |
Accessibility
- Pending removed words are a del element with a strike-through and added words an ins element with an underline; both carry visually hidden "(removed)" and "(added)", so color is never the only cue.
- Line diffs show a + or - in the gutter for added and removed lines, plus the same visually hidden words; the gutter matches the file-changes preview.
- Each change header shows its counts with a sign, for example "+3 -1", and reads them in words: "3 words added, 1 word removed" ("lines" in line mode).
- Card buttons are named with the change label, for example "Accept Paragraph 2" and "Decline Paragraph 2"; when the pressed button folds away while focused, focus moves to Undo, and after Undo it moves back to the button that returns.
- Leaving words and lines are aria-hidden once a decision is made, so the text reads as the result.
- A polite role="status" region says "3 left to review" and then "2 of 3 accepted".
- Buttons are 28px with a visible focus ring (Focus rings/focus-ring); hidden buttons are inert.