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
lcstable:lcs[i][j]is how many words the two texts still have in common from positioniandjonward. 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
pushhelper: glues neighbors of the same type together, so you get<del>is launching soon.</del>instead of three separate tags. textContent, neverinnerHTML: the suggestion came from a model, so treat it as untrusted text. ThereplaceChildrencall 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