$sloprun.dev

Diff Visualizer

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A real Myers diff — the same minimal-edit algorithm git runs — in about a hundred lines of plain JavaScript, wired live to two editable versions of a small function. The interesting part is what happens after the line diff: changed lines get diffed a second time at the word level, and the deleted/inserted lines inside a block are paired by similarity rather than by position, so const value = ... lines up with its own rewrite instead of with whatever happened to be next. Try editing either box and watching the hunks recompute, then hit Unified to read it as a patch. Added is amber and removed is red — never green/red, partly because green means "it ran" here, and partly because red-green is the one pair roughly one man in twelve can't tell apart.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/diff-visualizer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Diff Visualizer — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/diff-visualizer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1043 lines 44.1 KB index.html
demos/diff-visualizer/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Diff Visualizer — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* --- diff palette: amber = added, red = removed.
   Deliberately NOT green/red: green is reserved for "it worked", and
   amber/red survives red-green colour blindness. --- */
:root {
  --add-bg:#FFF4E8; --add-hi:#FFD9A8; --add-fg:#8A4200; --add-line:#E6B27C;
  --del-bg:#FDECEA; --del-hi:#F7C9C5; --del-fg:#9A2B27; --del-line:#E9A6A2;
  --fold-bg:#F0EFEA; --fill-bg:#FAFAF7;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --add-bg:#2E2611; --add-hi:#5C4A1C; --add-fg:#FFC880; --add-line:#5E4626;
    --del-bg:#301618; --del-hi:#5C2024; --del-fg:#FF9E96; --del-line:#5E2A27;
    --fold-bg:#22262E; --fill-bg:#181B21;
  }
}
:root[data-theme="dark"] {
  --add-bg:#2E2611; --add-hi:#5C4A1C; --add-fg:#FFC880; --add-line:#5E4626;
  --del-bg:#301618; --del-hi:#5C2024; --del-fg:#FF9E96; --del-line:#5E2A27;
  --fold-bg:#22262E; --fill-bg:#181B21;
}

*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;overflow-x:hidden}
body{-webkit-text-size-adjust:100%}
[hidden]{display:none !important}
.wrap{max-width:1140px;margin:0 auto;padding:18px 14px 26px}

h1{font-size:19px;margin:0;letter-spacing:-.01em;font-weight:650}
.head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px}
.lede{margin:6px 0 14px;color:var(--muted);font-size:13.5px;line-height:1.55;max-width:70ch}
.badge{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
  color:var(--accent);background:var(--accent-soft);border:1px solid var(--add-line);padding:2px 7px;border-radius:999px}

.btn{font-family:var(--font-sans);font-size:12.5px;color:var(--ink);background:var(--surface);
  border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;line-height:1.15}
.btn:hover:not(:disabled){border-color:var(--accent)}
.btn[aria-pressed="true"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);font-weight:600}
.btn:disabled{opacity:.42;cursor:default}
.btn--mono{font-family:var(--font-mono);font-size:11.5px;padding:6px 9px}
.seg{display:inline-flex;gap:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
.seg .btn{border:0;border-radius:0;padding:6px 12px}
.seg .btn + .btn{border-left:1px solid var(--line)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* the panes fill a clipped (overflow:hidden) card, so an outset ring would be
   shaved off by the card edge — draw theirs inside instead */
.pane:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}

.chip{font-family:var(--font-mono);font-size:11.5px;padding:3px 8px;border-radius:999px;
  border:1px solid var(--line);background:var(--surface);color:var(--muted);white-space:nowrap}
.chip b{font-weight:650}
.chip--add{color:var(--add-fg);background:var(--add-bg);border-color:var(--add-line)}
.chip--del{color:var(--del-fg);background:var(--del-bg);border-color:var(--del-line)}
.chip--ok{color:var(--run);background:var(--run-soft);border-color:var(--run)}
.chip--coarse{color:var(--ink);background:var(--fold-bg);border-color:var(--line);border-style:dashed;cursor:help}

.bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.bar--tools{margin:14px 0 10px;justify-content:space-between}
.grp{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.sep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
.lbl{font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
.mono{font-family:var(--font-mono);font-size:.92em;color:var(--ink)}
.note{color:var(--muted)}
.opt{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink);cursor:pointer;user-select:none}
.opt input{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}

.editors{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;min-width:0}
.card > header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line)}
.tag{font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.03em}
.tag--v1{color:var(--del-fg)}
.tag--v2{color:var(--add-fg)}
textarea{display:block;width:100%;border:0;background:transparent;color:var(--ink);
  font-family:var(--font-mono);font-size:12.5px;line-height:1.55;padding:8px 10px;
  resize:vertical;outline:none;tab-size:2;white-space:pre;overflow:auto;height:172px}
textarea:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}

.diff{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.sbs{display:grid;grid-template-columns:1fr 1fr}
.paneHead{padding:5px 10px;border-bottom:1px solid var(--line);background:var(--bg);min-width:0}
.sbs .paneHead:first-child{border-right:1px solid var(--line)}
.pane{overflow:auto;position:relative;max-height:clamp(220px,42vh,520px);min-height:120px;min-width:0;scrollbar-width:thin}
.sbs #paneL{border-right:1px solid var(--line)}
.rows{min-width:max-content;font-family:var(--font-mono);font-size:12.5px;line-height:20px}
.row{display:flex;align-items:stretch;min-height:20px;--row-bg:var(--surface);background:var(--row-bg)}
.row--del{--row-bg:var(--del-bg)}
.row--ins{--row-bg:var(--add-bg)}
.row--fill{--row-bg:var(--fill-bg)}
/* min-height 24 (not 20) so the inline "show them" control clears the 24px
   minimum target size; both panes get it, so the two sides stay row-aligned */
.row--fold{--row-bg:var(--fold-bg);color:var(--muted);font-size:11.5px;padding-left:10px;gap:8px;
  align-items:center;white-space:nowrap;min-height:24px}
.row.cur{box-shadow:inset 3px 0 0 var(--accent)}
.row.cur .gut:first-of-type{box-shadow:inset 3px 0 0 var(--accent)}
.gut{position:sticky;left:0;z-index:2;background:var(--row-bg);color:var(--muted);text-align:right;
  padding:0 7px;width:44px;flex:none;font-size:11px;user-select:none;font-variant-numeric:tabular-nums}
.uni .gut{width:40px}
.uni .gut + .gut{left:40px}
.mark{position:sticky;left:44px;z-index:2;background:var(--row-bg);width:16px;flex:none;text-align:center;
  border-right:1px solid var(--line);color:var(--muted);user-select:none}
.uni .mark{left:80px}
.row--del .mark{color:var(--del-fg);font-weight:700;background:var(--del-hi)}
.row--ins .mark{color:var(--add-fg);font-weight:700;background:var(--add-hi)}
.code{padding:0 10px;white-space:pre;flex:1 0 auto}
.w{background:var(--add-hi);border-radius:3px;box-shadow:0 0 0 1px var(--add-hi)}
.wd{background:var(--del-hi);border-radius:3px;box-shadow:0 0 0 1px var(--del-hi)}
.foldbtn{appearance:none;background:none;border:0;padding:2px 0;margin:0;font:inherit;
  font-family:var(--font-mono);color:var(--accent);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.same{padding:26px 16px;text-align:center;color:var(--muted);font-size:13.5px}
.same .chip{margin-top:8px;display:inline-block}

.hint{margin:10px 0 0;color:var(--muted);font-size:12px;line-height:1.6}
kbd{font-family:var(--font-mono);font-size:11px;border:1px solid var(--line);border-bottom-width:2px;
  border-radius:5px;padding:1px 5px;background:var(--surface);color:var(--ink)}
footer{margin-top:18px;padding-top:12px;border-top:1px solid var(--line);
  font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.04em}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

@media (max-width:760px){
  .editors{grid-template-columns:1fr}
  textarea{height:128px}
  .bar--tools{gap:10px}
  .sep{display:none}
}
/* phone widths: give the code back the room the gutters were eating */
@media (max-width:480px){
  /* on a phone the two editors stack, so keep them short enough that the diff
     itself — the point of the demo — is not pushed a screen and a half down */
  textarea{height:104px}
  .lede{font-size:13px;margin-bottom:10px}
  .rows{font-size:12px}
  .gut{width:30px;padding:0 5px}
  .mark{left:30px}
  .uni .gut{width:26px}
  .uni .gut + .gut{left:26px}
  .uni .mark{left:52px}
  .code{padding:0 6px}
}
@media (prefers-reduced-motion: reduce){
  *{animation-duration:.001ms !important;animation-iteration-count:1 !important;
    transition-duration:.001ms !important;scroll-behavior:auto !important}
}
</style>
</head>
<body>
<main class="wrap">
  <div class="head">
    <h1>Diff Visualizer</h1>
    <span class="badge">myers · line + word</span>
  </div>
  <p class="lede">
    Two versions of a small function, lined up by a real Myers diff — the same minimal-edit
    algorithm <span class="mono">git</span> uses. Changed lines are then diffed again word by word,
    so you see the edit inside the line. Type in either box and everything recomputes live.
  </p>

  <div class="bar">
    <span class="lbl">example</span>
    <button class="btn" type="button" data-preset="0" aria-pressed="true">Bytes formatter</button>
    <button class="btn" type="button" data-preset="2" aria-pressed="false">One-line guard</button>
    <button class="btn" type="button" data-preset="1" aria-pressed="false">Callbacks → async</button>
    <button class="btn" type="button" data-preset="3" aria-pressed="false">Module rewrite</button>
    <span class="sep" aria-hidden="true"></span>
    <button class="btn btn--mono" type="button" id="swap" title="Swap the two versions">⇄ swap</button>
    <button class="btn btn--mono" type="button" id="reset">↺ reset</button>
  </div>

  <div class="editors">
    <div class="card">
      <header>
        <span class="tag tag--v1">v1 · before</span>
        <span class="chip" id="countA">0 lines</span>
      </header>
      <label class="sr" for="ta">Version 1 source</label>
      <textarea id="ta" spellcheck="false" autocapitalize="off" autocorrect="off" wrap="off"></textarea>
    </div>
    <div class="card">
      <header>
        <span class="tag tag--v2">v2 · after</span>
        <span class="chip" id="countB">0 lines</span>
      </header>
      <label class="sr" for="tb">Version 2 source</label>
      <textarea id="tb" spellcheck="false" autocapitalize="off" autocorrect="off" wrap="off"></textarea>
    </div>
  </div>

  <div class="bar bar--tools">
    <div class="grp">
      <div class="seg" role="group" aria-label="Diff layout">
        <button class="btn" type="button" id="vSplit" aria-pressed="true">Side-by-side</button>
        <button class="btn" type="button" id="vUni" aria-pressed="false">Unified</button>
      </div>
      <span class="chip chip--add" id="chipAdd"><b>+0</b></span>
      <span class="chip chip--del" id="chipDel"><b>-0</b></span>
      <span class="chip" id="chipHunks">0 hunks</span>
      <span class="chip" id="chipTime">0.00 ms</span>
      <span class="chip chip--coarse" id="chipCoarse" hidden
            title="Too many differences to search exactly — the two sides are shown as a wholesale replacement.">approximate</span>
    </div>
    <div class="grp">
      <label class="opt"><input type="checkbox" id="optWs"> ignore whitespace</label>
      <label class="opt" id="optSyncWrap"><input type="checkbox" id="optSync" checked> sync scroll</label>
      <span class="sep" aria-hidden="true"></span>
      <button class="btn btn--mono" type="button" id="prev" title="Previous change (p)" aria-label="Previous change">↑</button>
      <span class="chip" id="chipNav">–/0</span>
      <button class="btn btn--mono" type="button" id="next" title="Next change (n)" aria-label="Next change">↓</button>
      <button class="btn btn--mono" type="button" id="copy">copy patch</button>
    </div>
  </div>

  <div class="diff">
    <div class="sbs" id="sbs">
      <div class="paneHead"><span class="tag tag--v1">v1 · before</span></div>
      <div class="paneHead"><span class="tag tag--v2">v2 · after</span></div>
      <div class="pane" id="paneL" tabindex="0" role="group" aria-label="Version 1, side-by-side diff"><div class="rows" id="rowsL"></div></div>
      <div class="pane" id="paneR" tabindex="0" role="group" aria-label="Version 2, side-by-side diff"><div class="rows" id="rowsR"></div></div>
    </div>
    <div id="uniWrap" hidden>
      <div class="paneHead"><span class="tag">unified · <span id="uniName">patch</span></span></div>
      <div class="pane uni" id="paneU" tabindex="0" role="group" aria-label="Unified diff"><div class="rows" id="rowsU"></div></div>
    </div>
    <div class="same" id="same" hidden>
      <span id="sameMsg">Both versions are identical — edit one of them to see a diff.</span>
      <br><span class="chip">0 changes</span>
    </div>
  </div>

  <p class="hint">
    <kbd>n</kbd> / <kbd>p</kbd> jump between changes · panes are focusable, so arrows and
    <kbd>PgDn</kbd> scroll them · amber is added, red is removed
    <span class="note">(not green — on sloprun, green means "it worked")</span>
  </p>
  <p class="sr" role="status" id="status"></p>

  <footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
  'use strict';
  var $ = function (s) { return document.querySelector(s); };

  /* ---------- platform theme bridge (also works standalone via prefers-color-scheme) ---------- */
  window.addEventListener('message', function (e) {
    var d = e.data;
    if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
      document.documentElement.setAttribute('data-theme', d.theme);
    }
  });

  /* =======================================================================
     Myers O(ND) diff — greedy forward pass with a saved trace, then backtrack.
     Generic over arrays, so the same code diffs lines and words.

     Cost scales with D, the number of differences — NOT with file size. So the
     guard rail below is a cap on D, not on how much text you paste: a 5000-line
     file with two edited lines is D=4 and finishes instantly. Only genuinely
     unrelated inputs run out of budget, and those fall back to an honest
     "replace the whole block" script with the UI saying it's approximate.
     ======================================================================= */
  var MAXD = 2000;      // ~16 MB of trace at the ceiling; a real edit never gets close
  var coarse = false;   // set when the cap above was hit; read + reset by compute()

  function replaceAll(a, b) {
    var out = [], i;
    for (i = 0; i < a.length; i++) out.push({ t: -1, ai: i, bi: -1 });
    for (i = 0; i < b.length; i++) out.push({ t: 1, ai: -1, bi: i });
    return out;
  }

  function myersCore(a, b, eq) {
    var N = a.length, M = b.length;
    if (!N && !M) return [];
    if (!N || !M) return replaceAll(a, b);

    var MAX = N + M, lim = MAX < MAXD ? MAX : MAXD;
    /* off is padded by one so the band slice below can never run off the front */
    var off = MAX + 1, v = new Int32Array(2 * MAX + 4), trace = [], D = -1, d, k, x, y, done = false;
    for (d = 0; d <= lim && !done; d++) {
      /* the backtrack only ever reads diagonals |k| <= d+1, so keep that band
         instead of a full copy of the front — the difference between a few MB
         and a few hundred on a big paste. Band d starts at index off-d-1. */
      trace.push(v.slice(off - d - 1, off + d + 2));
      for (k = -d; k <= d; k += 2) {
        if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) x = v[off + k + 1];
        else x = v[off + k - 1] + 1;
        y = x - k;
        while (x < N && y < M && eq(a[x], b[y])) { x++; y++; }
        v[off + k] = x;
        if (x >= N && y >= M) { D = d; done = true; break; }
      }
    }
    if (D < 0) { coarse = true; return replaceAll(a, b); }

    var ops = [], vp, prevK, prevX, prevY;
    x = N; y = M;
    for (d = D; d >= 0; d--) {
      vp = trace[d];        // band d: index i of the full front lives at i-(off-d-1)
      k = x - y;
      if (d === 0) { prevX = 0; prevY = 0; }
      else {
        if (k === -d || (k !== d && vp[k + d] < vp[k + d + 2])) prevK = k + 1;
        else prevK = k - 1;
        prevX = vp[prevK + d + 1];
        prevY = prevX - prevK;
      }
      while (x > prevX && y > prevY) { x--; y--; ops.push({ t: 0, ai: x, bi: y }); }
      if (d > 0) {
        if (x === prevX) ops.push({ t: 1, ai: -1, bi: prevY });
        else ops.push({ t: -1, ai: prevX, bi: -1 });
      }
      x = prevX; y = prevY;
    }
    ops.reverse();
    return ops;
  }

  /* Trim the common head/tail first: same result, far less work. */
  function diffSeq(a, b, eq) {
    eq = eq || function (p, q) { return p === q; };
    var ops = [], s = 0, ae = a.length, be = b.length, i;
    while (s < ae && s < be && eq(a[s], b[s])) s++;
    while (ae > s && be > s && eq(a[ae - 1], b[be - 1])) { ae--; be--; }
    for (i = 0; i < s; i++) ops.push({ t: 0, ai: i, bi: i });
    var mid = myersCore(a.slice(s, ae), b.slice(s, be), eq);
    for (i = 0; i < mid.length; i++) {
      ops.push({ t: mid[i].t, ai: mid[i].ai < 0 ? -1 : mid[i].ai + s, bi: mid[i].bi < 0 ? -1 : mid[i].bi + s });
    }
    for (i = ae; i < a.length; i++) ops.push({ t: 0, ai: i, bi: be + (i - ae) });
    return ops;
  }

  /* ---------- word-level diff inside a changed line ---------- */
  var TOKENS = /\s+|[A-Za-z0-9_$]+|[^\sA-Za-z0-9_$]/g;
  function tokenize(s) { return s.match(TOKENS) || []; }
  function pushPart(arr, t, s) {
    var last = arr[arr.length - 1];
    if (last && last.t === t) last.s += s; else arr.push({ t: t, s: s });
  }
  function wordDiff(l, r) {
    if (!l.trim() || !r.trim()) return null;
    var a = tokenize(l), b = tokenize(r);
    if (a.length + b.length > 1200) return null;
    var ops = diffSeq(a, b), same = 0, i, o;
    for (i = 0; i < ops.length; i++) if (ops[i].t === 0) same += a[ops[i].ai].trim().length;
    var la = l.trim().length, lb = r.trim().length;
    var sim = (la + lb) === 0 ? 1 : (2 * same) / (la + lb);
    var left = [], right = [];
    for (i = 0; i < ops.length; i++) {
      o = ops[i];
      if (o.t === 0) { pushPart(left, 'eq', a[o.ai]); pushPart(right, 'eq', b[o.bi]); }
      else if (o.t === -1) pushPart(left, 'del', a[o.ai]);
      else pushPart(right, 'ins', b[o.bi]);
    }
    [left, right].forEach(function (arr) {             // don't shout about pure whitespace
      arr.forEach(function (p) { if (p.t !== 'eq' && !p.s.trim()) p.t = 'eq'; });
    });
    return { sim: sim, left: left, right: right };
  }
  var PAIR_MIN = 0.4;   // below this two lines are unrelated: pair them, but no word highlight

  /* ---------- line ops -> aligned rows ----------
     Inside a changed block the deleted and inserted lines have to be paired up.
     Pairing them by position is what most viewers do, and it happily lines up
     unrelated lines. Instead: score every (deleted, inserted) candidate by its
     word-level similarity and pick the best monotonic matching (a small DP),
     then fill the gaps positionally so the block stays compact. */
  function emitPair(rows, A, B, d, s, w) {
    if (d && s) {
      var use = w && w.sim >= PAIR_MIN;
      rows.push({ k: 'mod', ln: d.ai, rn: s.bi, l: A[d.ai], r: B[s.bi],
                  lp: use ? w.left : null, rp: use ? w.right : null });
    } else if (d) rows.push({ k: 'del', ln: d.ai, l: A[d.ai] });
    else if (s) rows.push({ k: 'ins', rn: s.bi, r: B[s.bi] });
  }
  function emitPositional(rows, A, B, dels, inss) {
    var n = Math.max(dels.length, inss.length), j, d, s;
    for (j = 0; j < n; j++) {
      d = dels[j]; s = inss[j];
      emitPair(rows, A, B, d, s, (d && s) ? wordDiff(A[d.ai], B[s.bi]) : null);
    }
  }
  function emitBlock(rows, A, B, dels, inss) {
    var m = dels.length, n = inss.length, i, j, v;
    if (!m || !n || m * n > 600) { emitPositional(rows, A, B, dels, inss); return; }
    var wd = [], sim = [], w;
    for (i = 0; i < m; i++) {
      wd[i] = []; sim[i] = [];
      for (j = 0; j < n; j++) {
        w = wordDiff(A[dels[i].ai], B[inss[j].bi]);
        wd[i][j] = w; sim[i][j] = w ? w.sim : 0;
      }
    }
    var best = [];
    for (i = 0; i <= m; i++) { best[i] = []; for (j = 0; j <= n; j++) best[i][j] = 0; }
    for (i = m - 1; i >= 0; i--) for (j = n - 1; j >= 0; j--) {
      v = best[i + 1][j] > best[i][j + 1] ? best[i + 1][j] : best[i][j + 1];
      if (sim[i][j] >= PAIR_MIN && sim[i][j] + best[i + 1][j + 1] > v) v = sim[i][j] + best[i + 1][j + 1];
      best[i][j] = v;
    }
    var pi = 0, pj = 0;
    i = 0; j = 0;
    while (i < m && j < n) {
      if (sim[i][j] >= PAIR_MIN && sim[i][j] + best[i + 1][j + 1] >= best[i][j] - 1e-9) {
        emitPositional(rows, A, B, dels.slice(pi, i), inss.slice(pj, j));
        emitPair(rows, A, B, dels[i], inss[j], wd[i][j]);
        i++; j++; pi = i; pj = j;
      } else if (best[i + 1][j] >= best[i][j + 1]) i++;
      else j++;
    }
    emitPositional(rows, A, B, dels.slice(pi), inss.slice(pj));
  }

  function buildRows(A, B, ops) {
    var rows = [], i = 0, dels, inss;
    while (i < ops.length) {
      if (ops[i].t === 0) {
        rows.push({ k: 'eq', ln: ops[i].ai, rn: ops[i].bi, l: A[ops[i].ai], r: B[ops[i].bi] });
        i++; continue;
      }
      dels = []; inss = [];
      while (i < ops.length && ops[i].t !== 0) { (ops[i].t === -1 ? dels : inss).push(ops[i]); i++; }
      emitBlock(rows, A, B, dels, inss);
    }
    return rows;
  }

  /* collapse long unchanged runs, keeping 3 lines of context on each side */
  function foldRows(rows, expanded) {
    var out = [], i = 0, j, run, keepTop, keepBot, key, x;
    while (i < rows.length) {
      if (rows[i].k !== 'eq') { out.push(rows[i]); i++; continue; }
      j = i; while (j < rows.length && rows[j].k === 'eq') j++;
      run = j - i;
      /* key a fold by what it contains, not by where it sits in the row list:
         an index key shifts on every insertion above it, which quietly snaps
         open folds shut (or opens the wrong one) while you type */
      key = 'f' + run + ':' + rows[i].l + '…' + rows[j - 1].l;
      keepTop = i === 0 ? 0 : 3;
      keepBot = j === rows.length ? 0 : 3;
      if (run > 8 && run > keepTop + keepBot + 2 && !expanded[key]) {
        for (x = i; x < i + keepTop; x++) out.push(rows[x]);
        out.push({ k: 'fold', key: key, count: run - keepTop - keepBot });
        for (x = j - keepBot; x < j; x++) out.push(rows[x]);
      } else {
        for (x = i; x < j; x++) out.push(rows[x]);
      }
      i = j;
    }
    return out;
  }

  /* ---------- unified patch text ---------- */
  function makePatch(A, B, ops) {
    var ent = [], i, o, oa = 0, ob = 0;
    for (i = 0; i < ops.length; i++) {
      o = ops[i];
      ent.push({ t: o.t, text: o.t === 1 ? B[o.bi] : A[o.ai], oa: oa, ob: ob });
      if (o.t !== 1) oa++;
      if (o.t !== -1) ob++;
    }
    var ctx = 3, groups = [], start = -1, lastChange = -1;
    for (i = 0; i < ent.length; i++) {
      if (ent[i].t !== 0) {
        if (start < 0) start = Math.max(0, i - ctx);
        else if (i - lastChange > ctx * 2) { groups.push([start, Math.min(ent.length - 1, lastChange + ctx)]); start = i - ctx; }
        lastChange = i;
      }
    }
    if (start >= 0) groups.push([start, Math.min(ent.length - 1, lastChange + ctx)]);
    if (!groups.length) return '';
    var out = ['--- v1', '+++ v2'];
    for (i = 0; i < groups.length; i++) {
      var g = groups[i], a0 = ent[g[0]].oa, b0 = ent[g[0]].ob, ac = 0, bc = 0, body = [], x, e;
      for (x = g[0]; x <= g[1]; x++) {
        e = ent[x];
        if (e.t !== 1) ac++;
        if (e.t !== -1) bc++;
        body.push((e.t === 0 ? ' ' : e.t === 1 ? '+' : '-') + e.text);
      }
      out.push('@@ -' + (ac ? a0 + 1 : a0) + ',' + ac + ' +' + (bc ? b0 + 1 : b0) + ',' + bc + ' @@');
      out = out.concat(body);
    }
    return out.join('\n') + '\n';
  }

  /* ================================ examples ================================ */
  var PRESETS = [
    {
      name: 'format-bytes.js',
      a: [
        "export function formatBytes(bytes, digits = 1) {",
        "  if (bytes === 0) return '0 B';",
        "  const units = ['B', 'KB', 'MB', 'GB', 'TB'];",
        "  const i = Math.floor(Math.log(bytes) / Math.log(1024));",
        "  const value = bytes / Math.pow(1024, i);",
        "  return value.toFixed(digits) + ' ' + units[i];",
        "}"
      ].join('\n'),
      b: [
        "export function formatBytes(bytes, digits = 1) {",
        "  if (!Number.isFinite(bytes) || bytes < 1) return '0 B';",
        "  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];",
        "  const exp = Math.floor(Math.log(bytes) / Math.log(1024));",
        "  const i = Math.min(exp, units.length - 1);",
        "  const value = bytes / 1024 ** i;",
        "  return `${value.toFixed(digits)} ${units[i]}`;",
        "}"
      ].join('\n')
    },
    {
      name: 'fetch-user.js',
      a: [
        "function fetchUser(id, cb) {",
        "  var url = '/api/users/' + id;",
        "  request(url, function (err, res) {",
        "    if (err) {",
        "      cb(err);",
        "      return;",
        "    }",
        "    if (res.status !== 200) {",
        "      cb(new Error('bad status'));",
        "      return;",
        "    }",
        "    cb(null, JSON.parse(res.body));",
        "  });",
        "}"
      ].join('\n'),
      b: [
        "async function fetchUser(id, { signal } = {}) {",
        "  const url = `/api/users/${encodeURIComponent(id)}`;",
        "  const res = await request(url, { signal });",
        "  if (res.status !== 200) {",
        "    throw new HttpError('bad status', res.status);",
        "  }",
        "  return JSON.parse(res.body);",
        "}"
      ].join('\n')
    },
    {
      name: 'chunk.js',
      a: [
        "export function chunk(items, size) {",
        "  const out = [];",
        "  for (let i = 0; i < items.length; i += size) {",
        "    out.push(items.slice(i, i + size));",
        "  }",
        "  return out;",
        "}"
      ].join('\n'),
      b: [
        "export function chunk(items, size) {",
        "  if (size < 1) throw new RangeError('size must be >= 1');",
        "  const out = [];",
        "  for (let i = 0; i < items.length; i += size) {",
        "    out.push(items.slice(i, i + size));",
        "  }",
        "  return out;",
        "}"
      ].join('\n')
    },
    {
      name: 'rates.js',
      a: [
        "// rates.js — currency helpers",
        "const RATES = {",
        "  usd: 1,",
        "  eur: 0.92,",
        "  gbp: 0.79,",
        "  jpy: 157.2,",
        "};",
        "",
        "function round(n) {",
        "  return Math.round(n * 100) / 100;",
        "}",
        "",
        "export function convert(amount, from, to) {",
        "  const a = RATES[from];",
        "  const b = RATES[to];",
        "  if (!a || !b) {",
        "    return null;",
        "  }",
        "  return round((amount / a) * b);",
        "}",
        "",
        "export function symbolFor(code) {",
        "  switch (code) {",
        "    case 'usd': return '$';",
        "    case 'eur': return '€';",
        "    case 'gbp': return '£';",
        "    case 'jpy': return '¥';",
        "    default: return '';",
        "  }",
        "}",
        "",
        "export function format(amount, code) {",
        "  return symbolFor(code) + amount.toFixed(2);",
        "}",
        "",
        "export function total(rows, code) {",
        "  let sum = 0;",
        "  for (const row of rows) {",
        "    sum += convert(row.amount, row.code, code);",
        "  }",
        "  return round(sum);",
        "}"
      ].join('\n'),
      b: [
        "// rates.js — currency helpers",
        "const RATES = {",
        "  usd: 1,",
        "  eur: 0.94,",
        "  gbp: 0.79,",
        "  jpy: 155.8,",
        "};",
        "",
        "function round(n) {",
        "  return Math.round(n * 100) / 100;",
        "}",
        "",
        "export function convert(amount, from, to) {",
        "  const a = RATES[from];",
        "  const b = RATES[to];",
        "  if (!a || !b) {",
        "    throw new UnknownCurrency(from, to);",
        "  }",
        "  return round((amount / a) * b);",
        "}",
        "",
        "export function symbolFor(code) {",
        "  switch (code) {",
        "    case 'usd': return '$';",
        "    case 'eur': return '€';",
        "    case 'gbp': return '£';",
        "    case 'jpy': return '¥';",
        "    default: return '';",
        "  }",
        "}",
        "",
        "export function format(amount, code) {",
        "  return symbolFor(code) + amount.toFixed(2);",
        "}",
        "",
        "export function total(rows, code) {",
        "  return round(",
        "    rows.reduce((sum, row) => sum + convert(row.amount, row.code, code), 0)",
        "  );",
        "}"
      ].join('\n')
    }
  ];

  /* ================================ state + view ================================ */
  var ta = $('#ta'), tb = $('#tb'), rowsL = $('#rowsL'), rowsR = $('#rowsR'), rowsU = $('#rowsU'),
      paneL = $('#paneL'), paneR = $('#paneR'), paneU = $('#paneU'),
      sbs = $('#sbs'), uniWrap = $('#uniWrap'), sameBox = $('#same'), sameMsg = $('#sameMsg'),
      diffBox = $('.diff'), statusEl = $('#status');

  var state = { view: 'split', ws: false, sync: true, preset: 0, expanded: {}, cur: -1,
                rows: [], disp: [], hunks: 0, ms: 0, patch: '', adds: 0, dels: 0,
                coarse: false, rawDiffers: false };

  var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };

  function esc(s) {
    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }
  /* attribute values carry raw source lines (fold keys), so quotes have to go
     too — otherwise one `const s = "x"` in the file ends the attribute early
     and the fold button silently stops matching its own key. */
  function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
  function partsHTML(parts, cls, fallback) {
    if (!parts) return esc(fallback);
    var h = '', i, p;
    for (i = 0; i < parts.length; i++) {
      p = parts[i];
      h += p.t === 'eq' ? esc(p.s) : '<span class="' + cls + '">' + esc(p.s) + '</span>';
    }
    return h;
  }
  function splitLines(v) { return v.replace(/\n$/, '').split('\n'); }
  function norm(s) { return s.replace(/\s+/g, ' ').trim(); }
  function hAttr(r) { return r.h == null ? '' : ' data-hunk="' + r.h + '"'; }

  function rowHTML(cls, guts, mark, code, extra) {
    var g = '', i;
    for (i = 0; i < guts.length; i++) g += '<span class="gut">' + guts[i] + '</span>';
    return '<div class="row' + (cls ? ' ' + cls : '') + '"' + (extra || '') + '>' + g +
           '<span class="mark">' + mark + '</span><span class="code">' + code + '</span></div>';
  }
  function foldHTML(r, interactive) {
    var label = '⋯ ' + r.count + ' unchanged line' + (r.count === 1 ? '' : 's');
    var btn = interactive
      ? '<button class="foldbtn" type="button" data-fold="' + escAttr(r.key) + '">show them</button>'
      : '';
    return '<div class="row row--fold">' + label + ' ' + btn + '</div>';
  }

  /* ---------- renderers ---------- */
  function renderSplit(disp) {
    var L = [], R = [], i, r, a;
    for (i = 0; i < disp.length; i++) {
      r = disp[i]; a = hAttr(r);
      if (r.k === 'fold') { L.push(foldHTML(r, true)); R.push(foldHTML(r, false)); continue; }
      if (r.k === 'eq') {
        L.push(rowHTML('', [r.ln + 1], ' ', esc(r.l), a));
        R.push(rowHTML('', [r.rn + 1], ' ', esc(r.r), a));
      } else if (r.k === 'del') {
        L.push(rowHTML('row--del', [r.ln + 1], '-', esc(r.l), a));
        R.push(rowHTML('row--fill', [''], ' ', '', a));
      } else if (r.k === 'ins') {
        L.push(rowHTML('row--fill', [''], ' ', '', a));
        R.push(rowHTML('row--ins', [r.rn + 1], '+', esc(r.r), a));
      } else {
        L.push(rowHTML('row--del', [r.ln + 1], '-', partsHTML(r.lp, 'wd', r.l), a));
        R.push(rowHTML('row--ins', [r.rn + 1], '+', partsHTML(r.rp, 'w', r.r), a));
      }
    }
    rowsL.innerHTML = L.join('');
    rowsR.innerHTML = R.join('');
  }

  function renderUnified(disp) {
    var U = [], i, r, a;
    for (i = 0; i < disp.length; i++) {
      r = disp[i]; a = hAttr(r);
      if (r.k === 'fold') { U.push(foldHTML(r, true)); continue; }
      if (r.k === 'eq') U.push(rowHTML('', [r.ln + 1, r.rn + 1], ' ', esc(r.l), a));
      else if (r.k === 'del') U.push(rowHTML('row--del', [r.ln + 1, ''], '-', esc(r.l), a));
      else if (r.k === 'ins') U.push(rowHTML('row--ins', ['', r.rn + 1], '+', esc(r.r), a));
      else {
        U.push(rowHTML('row--del', [r.ln + 1, ''], '-', partsHTML(r.lp, 'wd', r.l), a));
        U.push(rowHTML('row--ins', ['', r.rn + 1], '+', partsHTML(r.rp, 'w', r.r), a));
      }
    }
    rowsU.innerHTML = U.join('');
  }

  /* ---------- compute + paint ---------- */
  function compute() {
    var A = splitLines(ta.value), B = splitLines(tb.value), i;
    var t0 = (window.performance || Date).now();
    var ops;
    coarse = false;
    if (state.ws) {
      var An = [], Bn = [];
      for (i = 0; i < A.length; i++) An.push(norm(A[i]));
      for (i = 0; i < B.length; i++) Bn.push(norm(B[i]));
      ops = diffSeq(An, Bn);
    } else {
      ops = diffSeq(A, B);
    }
    state.coarse = coarse;              // the line diff ran out of budget, say so
    state.rawDiffers = ta.value !== tb.value;
    var rows = buildRows(A, B, ops);
    state.ms = (window.performance || Date).now() - t0;
    state.rows = rows;
    state.patch = makePatch(A, B, ops);
    var adds = 0, dels = 0;
    for (i = 0; i < rows.length; i++) {
      if (rows[i].k === 'ins') adds++;
      else if (rows[i].k === 'del') dels++;
      else if (rows[i].k === 'mod') { adds++; dels++; }
    }
    state.adds = adds; state.dels = dels;
    $('#countA').textContent = A.length + ' line' + (A.length === 1 ? '' : 's');
    $('#countB').textContent = B.length + ' line' + (B.length === 1 ? '' : 's');
    render();
  }

  function render() {
    var disp = foldRows(state.rows, state.expanded), i, r, h = -1, inChange = false;
    for (i = 0; i < disp.length; i++) {
      r = disp[i];
      if (r.k === 'eq' || r.k === 'fold') { r.h = null; inChange = false; }
      else { if (!inChange) h++; r.h = h; inChange = true; }
    }
    state.disp = disp;
    state.hunks = h + 1;
    if (state.cur >= state.hunks) state.cur = state.hunks - 1;

    var identical = state.hunks === 0;
    sameBox.hidden = !identical;
    if (identical) {
      /* "no hunks" is not always "same text": with whitespace ignored the two
         sides can still differ by spacing, and claiming otherwise would lie. */
      sameMsg.textContent = !state.rawDiffers
        ? 'Both versions are identical — edit one of them to see a diff.'
        : state.ws
          ? 'No differences except whitespace — untick “ignore whitespace” to see the spacing changes.'
          : 'The two versions differ only by a trailing newline — nothing to show line by line.';
    }
    sbs.hidden = identical || state.view !== 'split';
    uniWrap.hidden = identical || state.view !== 'unified';

    if (identical) { rowsL.innerHTML = rowsR.innerHTML = rowsU.innerHTML = ''; }
    else if (state.view === 'split') { rowsU.innerHTML = ''; renderSplit(disp); }
    else { rowsL.innerHTML = rowsR.innerHTML = ''; renderUnified(disp); }
    paintCurrent();

    $('#chipAdd').innerHTML = '<b>+' + state.adds + '</b>';
    $('#chipDel').innerHTML = '<b>-' + state.dels + '</b>';
    $('#chipHunks').textContent = state.hunks + ' hunk' + (state.hunks === 1 ? '' : 's');
    $('#chipTime').textContent = state.ms < 0.005 ? '< 0.01 ms' : state.ms.toFixed(2) + ' ms';
    $('#chipCoarse').hidden = !state.coarse;
    $('#chipNav').textContent = (state.cur < 0 ? '–' : state.cur + 1) + '/' + state.hunks;
    $('#prev').disabled = state.hunks === 0;
    $('#next').disabled = state.hunks === 0;
    $('#copy').disabled = state.hunks === 0;
    $('#optSyncWrap').hidden = state.view !== 'split';
    /* the fallback patch panel is a second view of the same state — if it is
       open while you keep typing it has to follow along, not sit on stale text */
    if (patchPanel && !patchPanel.hidden) patchPanel._out.value = state.patch;
  }

  function activePanes() {
    return state.view === 'split' ? [paneL, paneR] : [paneU];
  }
  function paintCurrent() {
    var old = document.querySelectorAll('.row.cur'), i;
    for (i = 0; i < old.length; i++) old[i].classList.remove('cur');
    if (state.cur < 0) return;
    var now = document.querySelectorAll('[data-hunk="' + state.cur + '"]');
    for (i = 0; i < now.length; i++) now[i].classList.add('cur');
  }
  function gotoHunk(idx) {
    if (!state.hunks) return;
    state.cur = (idx + state.hunks) % state.hunks;
    paintCurrent();
    var panes = activePanes(), i, el, top;
    for (i = 0; i < panes.length; i++) {
      el = panes[i].querySelector('[data-hunk="' + state.cur + '"]');
      if (!el) continue;
      top = Math.max(0, el.offsetTop - panes[i].clientHeight * 0.3);
      if (panes[i].scrollTo) panes[i].scrollTo({ top: top, behavior: reduced.matches ? 'auto' : 'smooth' });
      else panes[i].scrollTop = top;
    }
    $('#chipNav').textContent = (state.cur + 1) + '/' + state.hunks;
    say('change ' + (state.cur + 1) + ' of ' + state.hunks);
  }
  function say(msg) { statusEl.textContent = msg; }

  /* ---------- synchronized scrolling ---------- */
  var lock = false;
  function link(a, b) {
    a.addEventListener('scroll', function () {
      if (!state.sync || lock || state.view !== 'split') return;
      lock = true;
      b.scrollTop = a.scrollTop;
      b.scrollLeft = a.scrollLeft;
      requestAnimationFrame(function () { lock = false; });
    }, { passive: true });
  }
  link(paneL, paneR);
  link(paneR, paneL);

  /* ---------- wiring ---------- */
  var timer = null;
  function onEdit() {
    if (timer) clearTimeout(timer);
    timer = setTimeout(function () { timer = null; compute(); }, 110);
  }
  ta.addEventListener('input', onEdit);
  tb.addEventListener('input', onEdit);

  function loadPreset(i) {
    var p = PRESETS[i];
    state.preset = i; state.expanded = {}; state.cur = -1;
    ta.value = p.a; tb.value = p.b;
    $('#uniName').textContent = p.name;
    var btns = document.querySelectorAll('[data-preset]'), k;
    for (k = 0; k < btns.length; k++) {
      btns[k].setAttribute('aria-pressed', String(Number(btns[k].getAttribute('data-preset')) === i));
    }
    paneL.scrollTop = paneR.scrollTop = paneU.scrollTop = 0;
    compute();
  }
  document.querySelectorAll('[data-preset]').forEach(function (b) {
    b.addEventListener('click', function () {
      loadPreset(Number(b.getAttribute('data-preset')));
      say('loaded example ' + $('#uniName').textContent);
    });
  });
  $('#reset').addEventListener('click', function () {
    loadPreset(state.preset);
    say('example reset');
  });
  $('#swap').addEventListener('click', function () {
    var t = ta.value; ta.value = tb.value; tb.value = t;
    state.expanded = {}; state.cur = -1;
    compute();
    say('versions swapped');
  });

  function setView(v) {
    state.view = v;
    $('#vSplit').setAttribute('aria-pressed', String(v === 'split'));
    $('#vUni').setAttribute('aria-pressed', String(v === 'unified'));
    render();
    say(v === 'split' ? 'side by side view' : 'unified view');
  }
  $('#vSplit').addEventListener('click', function () { setView('split'); });
  $('#vUni').addEventListener('click', function () { setView('unified'); });

  $('#optWs').addEventListener('change', function () {
    state.ws = this.checked;
    compute();
    say(state.ws ? 'ignoring whitespace' : 'whitespace significant');
  });
  $('#optSync').addEventListener('change', function () {
    state.sync = this.checked;
    if (state.sync) paneR.scrollTop = paneL.scrollTop;
    say(state.sync ? 'scroll linked' : 'scroll unlinked');
  });

  $('#next').addEventListener('click', function () { gotoHunk(state.cur + 1); });
  $('#prev').addEventListener('click', function () { gotoHunk(state.cur < 0 ? -1 : state.cur - 1); });

  diffBox.addEventListener('click', function (e) {
    var b = e.target.closest ? e.target.closest('[data-fold]') : null;
    if (!b) return;
    state.expanded[b.getAttribute('data-fold')] = true;
    render();
    say('unchanged lines expanded');
  });

  document.addEventListener('keydown', function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    var t = e.target, tag = t && t.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || (t && t.isContentEditable)) return;
    if (!e.key) return;
    var k = e.key.toLowerCase();
    if (k === 'n' || k === 'j') { e.preventDefault(); gotoHunk(state.cur + 1); }
    else if (k === 'p' || k === 'k') { e.preventDefault(); gotoHunk(state.cur < 0 ? -1 : state.cur - 1); }
  });

  /* ---------- copy patch (with a real fallback if the sandbox blocks it) ---------- */
  var copyBtn = $('#copy'), copyTimer = null, patchPanel = null;
  function copyDone(ok) {
    if (copyTimer) clearTimeout(copyTimer);
    copyBtn.textContent = ok ? '✓ copied' : 'patch below ↓';
    copyBtn.classList.toggle('chip--ok', !!ok);
    copyBtn.style.color = ok ? 'var(--run)' : '';
    copyBtn.style.borderColor = ok ? 'var(--run)' : '';
    say(ok ? 'patch copied to clipboard' : 'clipboard blocked; patch shown below, already selected');
    copyTimer = setTimeout(function () {
      copyBtn.textContent = 'copy patch';
      copyBtn.style.color = ''; copyBtn.style.borderColor = '';
      copyBtn.classList.remove('chip--ok');
    }, 1800);
  }
  function showPatchPanel() {
    if (!patchPanel) {
      patchPanel = document.createElement('div');
      patchPanel.className = 'card';
      patchPanel.style.marginTop = '10px';
      patchPanel.innerHTML = '<header><span class="tag">unified patch · select and copy</span>' +
        '<button class="btn btn--mono" type="button" data-closepatch>close</button></header>';
      var out = document.createElement('textarea');
      out.setAttribute('readonly', '');
      out.setAttribute('aria-label', 'Unified patch text');
      out.style.height = '140px';
      patchPanel.appendChild(out);
      patchPanel.addEventListener('click', function (ev) {
        if (ev.target.closest && ev.target.closest('[data-closepatch]')) {
          patchPanel.hidden = true; copyBtn.focus(); say('patch panel closed');
        }
      });
      diffBox.parentNode.insertBefore(patchPanel, diffBox.nextSibling);
      patchPanel._out = out;
    }
    patchPanel.hidden = false;
    patchPanel._out.value = state.patch;
    patchPanel._out.focus();
    patchPanel._out.select();
  }
  function legacyCopy() {
    var ok = false, tmp;
    try {
      tmp = document.createElement('textarea');
      tmp.value = state.patch;
      tmp.setAttribute('readonly', '');
      tmp.style.cssText = 'position:fixed;top:0;left:-9999px';
      document.body.appendChild(tmp);
      tmp.select();
      ok = document.execCommand('copy');
      document.body.removeChild(tmp);
    } catch (err) { ok = false; }
    if (!ok) showPatchPanel();
    copyDone(ok);
  }
  copyBtn.addEventListener('click', function () {
    if (!state.patch) return;
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(state.patch).then(function () { copyDone(true); }, legacyCopy);
    } else legacyCopy();
  });

  /* ---------- go ---------- */
  if (window.innerWidth < 620) { state.view = 'unified'; setView('unified'); }
  loadPreset(0);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong