Lunia DS

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.

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-review

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
changesArray<{ 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).
decisionsRecord<string, "pending" | "accepted" | "declined">undefinedControlled decisions by change id; missing ids are pending.
defaultDecisionsRecord<string, "pending" | "accepted" | "declined">{}Decisions at first when not controlled.
readOnlybooleanfalseShows the decisions without buttons or Undo, for example after they were sent.
titlestring"Proposed changes"Header title.
EventDescription
decisionsChangeDecisions changed (one call for Accept all or Decline all). Payload: every decision by id.
decisionChangeOne change got a decision or was undone. Payload: { id, decision }.
completeThe 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
DRV1A decision is made or undoneheader count (pending, then accepted and total)number rollold count → new countduration-5xl (640)ease-outSwaps at once
DRV2The last pending change gets a decisionheader count wordstext morphleft to review → acceptedduration-3xl (460)ease-outSwaps at once
DRV3A piece shows or hides"of N" in the count, decline all and accept all, card decline and accept buttons, undowidth (grid columns)0fr → 1frduration-2xl (420)ease-outResizes at once
DRV4A piece shows"of N" in the count, decline all and accept all, card decline and accept buttons, undoopacity, blur0, blur-swap → 1, 0duration-lg (320)ease-outShows at once
DRV5A piece hides"of N" in the count, decline all and accept all, card decline and accept buttons, undoopacity1 → 0duration-xs (160)ease-outHides at once
DRV6Accept or declinepressed button labeltext morphAccept, Decline → Accepted, Declinedduration-3xl (460)ease-outSwaps at once
DRV7Acceptplain checkopacity1 → 0duration-sm (200)ease-outHides at once
DRV8Acceptdrawn checkstroke drawn0% → 100%duration-2xl (420)ease-outdelay 120msShows at once
DRV9Accept (removed words) or decline (added words)leaving wordsfont size, margin (the line closes up)inherit, spacing-xxs → 0, 0duration-3xl (460)ease-outResizes at once
DRV10Accept or declineleaving wordsopacity1 → 0duration-sm (200)ease-outHides at once
DRV11Accept or declinestaying wordsbackground color, text color, line colorbg-success-primary or bg-error-primary, text-success-primary or text-error-primary → transparent, inheritduration-lg (320)ease-outSwaps at once
DRV12Accept (removed lines) or decline (added lines)leaving linesheight (grid rows)line height → 0duration-2xl (420)ease-outResizes at once
DRV13Accept or declineleaving linesopacity1 → 0duration-sm (200)ease-outHides at once
DRV14Accept or declinestaying linesbackground color, text color, gutter opacitytint, 1 → transparent, 0duration-lg (320)ease-outSwaps at once
DRV15Decline, or undo a declinechange texttext colortext-primary → text-tertiaryduration-lg (320)ease-outSwaps at once
DRV16A decision is made or undonechange cardborder colorborder-secondary → border-tertiaryduration-lg (320)ease-outSwaps at once
DRV17Hoverdecline, accept and all buttonsbackground color, text colortransparent, text-tertiary → bg-primary_hover, text-primaryduration-xs (160)ease-outSwaps 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.