cd /news/ai-tools/show-users-exactly-what-the-ai-chang… · home › topics › ai-tools › article
[ARTICLE · art-169486] src=markodenic.tech ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Show users exactly what the AI changed before they accept it

A developer tutorial published in the newsletter outlines a roughly 30-line JavaScript approach for showing users a word-level diff of AI-suggested text edits before they accept them, using a longest-common-subsequence comparison and native `<del>` and `<ins>` elements. The tutorial argues that without such a preview, users can ship AI rewrites that silently change facts — its example is a landing page that says $39 while checkout charges $49 — and it recommends rendering model output with `textContent` rather than `innerHTML` because the suggestion is untrusted. The technique requires no library and lets users edit the suggestion while the diff updates.

by read5 min views3 publishedNov 14, 2026
Show users exactly what the AI changed before they accept it
Image: Markodenic (auto-discovered)

Creem.io

Merchant of record payments for SaaS and digital products, handling checkout, subscriptions, taxes, invoices, and global payment operations.

Sponsor this newsletter · reach 9,500+ active developers

You add an “Improve with AI” button to your editor. The user clicks it, a polished paragraph comes back, and it replaces theirs. It reads well, so they ship it. Two days later a customer asks why the landing page says $39 when checkout charges $49. The AI “improved” the price too.

The user didn’t miss it because they were careless. Nobody re-reads a whole paragraph hunting for a changed word. Code editors solved this decades ago with a diff: removed words struck through in red, added words in green. Your text fields can do the same in about 30 lines, no library.

The fix #

Compare the two texts word by word and keep what they share:

function diffWords(before, after) {
  const a = before.match(/\S+\s*/g) ?? [];
  const b = after.match(/\S+\s*/g) ?? [];
  const lcs = a.map(() => new Array(b.length + 1).fill(0));
  lcs.push(new Array(b.length + 1).fill(0));

  for (let i = a.length - 1; i >= 0; i--) {
    for (let j = b.length - 1; j >= 0; j--) {
      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
    }
  }

  const parts = [];
  const push = (type, text) => {
    const last = parts.at(-1);
    if (last?.type === type) last.text += text;
    else parts.push({ type, text });
  };

  let i = 0, j = 0;
  while (i < a.length || j < b.length) {
    if (a[i] === b[j]) { push('same', a[i]); i++; j++; }
    else if (j === b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1])) push('del', a[i++]);
    else push('ins', b[j++]);
  }
  return parts;
}

Then render it with real <del> and <ins> elements, and only replace the text when the user says so:

function renderDiff(parts, container) {
  container.replaceChildren(...parts.map(({ type, text }) => {
    if (type === 'same') return text;
    const el = document.createElement(type);
    el.textContent = text;
    return el;
  }));
}

const suggestion = await askModel(`Improve this text:\n\n${editor.value}`);
renderDiff(diffWords(editor.value, suggestion), preview);

acceptButton.onclick = () => { editor.value = suggestion; };
rejectButton.onclick = () => { preview.replaceChildren(); };
ins { background: #dcfce7; color: #166534; text-decoration: none; }
del { background: #fee2e2; color: #991b1b; }

askModel() stands in for whatever call your app already makes to its LLM.

Click “Improve with AI”, then edit the suggestion yourself and watch the diff update as you type.

How it works #

The diff is built on the longest common subsequence: the longest run of words both texts share, in order. Everything in that run is unchanged. Whatever is left over in the original was deleted, and whatever is left over in the suggestion was added.

  • The lcs table:lcs[i][j] is how many words the two texts still have in common from positioni andj onward. It is filled backwards, from the end of both texts.
  • The walk: start at the beginning of both. Same word, keep it. Different word, follow whichever choice keeps more words in common.
  • The push helper: glues neighbors of the same type together, so you get<del>is launching soon.</del> instead of three separate tags.
  • textContent, never innerHTML: the suggestion came from a model, so treat it as untrusted text. ThereplaceChildren call takes plain strings and elements, nothing gets parsed as HTML.

Choosing the granularity #

The tokenizer is the one regex, /\S+\s*/g, and swapping it changes the whole feel:

text.match(/\S+\s*/g)           // words: the right default for prose
text.match(/[^.!?]+[.!?]*\s*/g) // sentences: long documents, fewer marks
[...text]                       // characters: typo fixes, short labels
  • Words: what writing tools show. A rewritten clause reads as one red block and one green block.
  • Sentences: for a full article, where word-level marks turn the page into confetti.
  • Characters: for spelling fixes and product names, where one changed letter matters. Avoid it for long text, the table gets big fast.

Make it readable for screen readers #

Most screen readers don’t announce <del> and <ins> by default, so the change is silent. Add hidden labels with CSS:

del::before, del::after, ins::before, ins::after {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
del::before { content: " [removed: "; }
ins::before { content: " [added: "; }
del::after, ins::after { content: "] "; }

Sighted users see the colors, screen reader users hear “removed: is launching soon, added: launches on Monday”.

The rough edges #

  • Size: the table has one cell per word pair. Two 500 word paragraphs is 250,000 cells and runs in a few milliseconds. A full 10,000 word chapter is 100 million, so diff paragraph by paragraph instead.
  • Moved text: a sentence moved from the end to the start shows as deleted in one place and added in the other. That is honest, just noisier than a “moved” marker.
  • Whitespace counts: a word followed by a line break is a different token than the same word followed by a space. Normalize line breaks first if your editor mixes them.

Where this comes up #

  • “Improve writing” buttons: in CMS editors, email composers, product description fields.
  • AI-suggested fixes in forms: address cleanup, title rewrites, SEO meta descriptions.
  • Content review: showing an editor what changed between two drafts, AI or not.
  • Streaming suggestions: once thestreamed answer finishes, run the diff on the final text.

Why it matters #

  • Trust: users accept AI edits faster when they can see that nothing else moved.
  • Catches the quiet mistakes: a changed date, a dropped “not”, a softened price. The exact things a model gets wrong while sounding right.
  • Keeps the user in charge: the AI suggests, the human decides. Accept and Reject make that explicit.
  • Zero dependencies: one function, two elements HTML already ships, same idea ashighlighting search matches without touching the DOM : let the browser do the visual work.

Find the place in your app where an AI result overwrites user text without asking, and put this diff in front of it.

Happy coding!

Marko

── more in #ai-tools 4 stories · sorted by recency
── more on @creem.io 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-users-exactly-w…] indexed:0 read:5min 2026-11-14 · —