diff

<tosi-diff> renders a unified, line-by-line diff between two strings — the kind of view you'd see in a code review, with removed lines marked - and added lines marked +.

Set its original and modified properties (or attributes) to the two versions of the text; it recomputes and re-renders. (They're named original/modified rather than before/after to avoid the native Element.before()/after() methods.)

const { tosiDiff } = tosijsui

preview.append(
  tosiDiff({
    original: 'one\ntwo\nthree\nfour',
    modified: 'one\nTWO\nthree\nfour\nfive',
    style: { width: '100%', height: '100%' },
  })
)

The diff is computed with a longest-common-subsequence pass over the lines, so unchanged lines are shown as context and only genuine insertions/deletions are highlighted. The pure diffLines(before, after) function is exported too, if you just want the data.

Resolving a diff — "do I want to accept this?"

Set resolvable and each change gets a pair of buttons: keep the original, or take the modified. value is the text those choices imply, and a change event fires whenever one moves. That makes it a review surface for a proposed edit — an AI suggestion, a collaborator's revision, a file that moved under you while you were editing it.

The labels are yours. "Mine"/"Theirs" is git's framing, "Current"/"Proposed" suits reviewing a suggestion, and the component's own vocabulary is original/modified — so nothing is hardcoded. Pass already-localized strings; the host owns that choice.

const { tosiDiff } = tosijsui

const diff = tosiDiff({
  original: 'The cat sat on the mat.\nIt was a sunny day.\nThe end.',
  modified: 'The cat sprawled across the mat.\nIt was a sunny day.\nFin.',
  resolvable: true,
  originalLabel: 'Mine',
  modifiedLabel: 'Theirs',
  style: { width: '100%' },
})
diff.addEventListener('change', () => {
  result.textContent = JSON.stringify(diff.value)
})
const result = document.createElement('pre')
result.textContent = JSON.stringify(diff.value)
preview.append(diff, result)
const { tosiDiff } = tosijsui
const el = tosiDiff({
  original: 'a\nKEEP\nc\nDROP\ne',
  modified: 'a\nNEW1\nc\nNEW2\ne',
  resolvable: true,
})
document.body.append(el)

test('accepting everything yields the modified text', () => {
  expect(el.changeCount).toBe(2)
  expect(el.value).toBe('a\nNEW1\nc\nNEW2\ne')
})

test('rejecting everything yields the original text exactly', () => {
  el.rejectAll()
  expect(el.value).toBe('a\nKEEP\nc\nDROP\ne')
  el.remove()
})

Click the coloured text itself to pick a side — the line is the affordance, so reaching for a small button to say "this one" when the thing is right under the cursor is friction with nothing behind it. Dragging to select text does not change anything: those lines are also what you copy from.

Within a changed line, the words that actually differ are marked more strongly than the line around them. A whole-line wash says "something here changed", which for a one-word edit throws away most of the signal — so the line colour tells you which side you are on, and the run tells you what moved. diffTokens(before, after) is exported if you want that data directly; its runs reassemble each input exactly, and it falls back to marking the whole line for lines too long to diff cheaply.

The unit of decision is a change block, not a line: a multi-line edit is one choice, because accepting half of one produces text neither side wrote. Two more exports give you the same model without the DOM — diffBlocks(diffLines(a, b)) for the blocks, and resolveDiff(blocks, choices) to turn choices back into text.

acceptAll() and rejectAll() do the obvious thing, changeCount tells you how many decisions the diff is asking for (0 means the texts agree), and resolutions is readable and writable if you want to drive it yourself. Resolutions reset when either text changes — decisions belong to the diff they were made about.