/* Code review threads: + starts a thread on a line, Comment appends to it, Resolve collapses it (and clears the highlight
on a document passage), and hovering or focusing a margin comment highlights its passage. Frontend only. */
document.querySelectorAll('[data-review]').forEach(function (root) {
var live = root.querySelector('[data-announce]');
var tpl = function (n) { return root.querySelector('template[data-tpl="' + n + '"]').content.firstElementChild.cloneNode(true); };
root.addEventListener('click', function (e) {
var add = e.target.closest('[data-add-line]'), res = e.target.closest('[data-resolve]'), cancel = e.target.closest('[data-cancel]');
if (add) {
var line = add.closest('[data-line]'), next = line.nextElementSibling;
if (next && next.matches('[data-thread]')) { if (next.hasAttribute('data-resolved')) next.querySelector('[data-resolve]').click(); next.querySelector('textarea').focus(); return; }
var t = tpl('thread'), field = t.querySelector('textarea');
t.querySelectorAll('[data-line-no]').forEach(function (s) { s.textContent = add.dataset.addLine; });
field.id = 'cr-new-' + add.dataset.addLine; t.querySelector('label').htmlFor = field.id;
line.after(t); field.focus();
} else if (res) {
var th = res.closest('[data-thread]'), on = !th.hasAttribute('data-resolved');
th.toggleAttribute('data-resolved', on);
th.querySelector('[data-body]').hidden = on;
res.querySelector('[data-label]').textContent = on ? 'Unresolve' : 'Resolve conversation';
if (th.dataset.mark) document.getElementById(th.dataset.mark).toggleAttribute('data-resolved', on);
live.textContent = on ? 'Conversation resolved.' : 'Conversation reopened.';
} else if (cancel) {
var thr = cancel.closest('[data-thread]');
if (thr.querySelector('[data-list]').children.length) { thr.querySelector('textarea').value = ''; return; }
var btn = thr.previousElementSibling.querySelector('[data-add-line]');
thr.remove(); btn.focus();
}
});
root.addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target, field = form.querySelector('textarea'), text = field.value.trim(), th = form.closest('[data-thread]');
if (!text) { field.focus(); return; }
var c = tpl('comment');
c.querySelector('[data-text]').textContent = text;
c.querySelector('time').dateTime = new Date().toISOString();
th.querySelector('[data-list]').appendChild(c);
th.querySelectorAll('[data-after-first]').forEach(function (x) { x.hidden = false; });
var x = form.querySelector('[data-cancel]'); if (x) x.remove();
field.value = ''; field.placeholder = 'Reply…';
live.textContent = 'Comment added.';
});
root.querySelectorAll('[data-mark]').forEach(function (note) {
var mark = document.getElementById(note.dataset.mark);
var on = function () { mark.setAttribute('data-active', ''); }, off = function () { mark.removeAttribute('data-active'); };
note.addEventListener('mouseenter', on); note.addEventListener('mouseleave', off);
note.addEventListener('focusin', on); note.addEventListener('focusout', off);
});
});
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div><span class="lbl">Diff with a comment thread pinned to line 15</span>
<section class="card cr" data-review aria-label="Review of src/cart/total.js">
<p class="cr-file"><b>src/cart/total.js</b> <span class="cr-stat"><span class="a">+2</span> <span class="d">−1</span></span></p>
<div class="cr-diff">
<div class="cr-line" data-line><button class="btn cr-add" type="button" data-add-line="12"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 12</span></button><span class="cr-num" aria-hidden="true">12</span><span class="cr-sign" aria-hidden="true"></span><code class="cr-code"><span class="k">export function</span> <span class="fn">cartTotal</span>(items, coupon) {</code></div>
<div class="cr-line" data-line><button class="btn cr-add" type="button" data-add-line="13"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 13</span></button><span class="cr-num" aria-hidden="true">13</span><span class="cr-sign" aria-hidden="true"></span><code class="cr-code"> <span class="k">const</span> subtotal = items.<span class="fn">reduce</span>((sum, i) => sum + i.price * i.qty, <span class="n">0</span>);</code></div>
<div class="cr-line del" data-line><span></span><span class="cr-num" aria-hidden="true">14</span><span class="cr-sign" aria-hidden="true">−</span><code class="cr-code"><span class="visually-hidden">Removed: </span> <span class="k">const</span> discount = coupon ? subtotal * coupon.rate : <span class="n">0</span>;</code></div>
<div class="cr-line add" data-line><button class="btn cr-add" type="button" data-add-line="14"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 14</span></button><span class="cr-num" aria-hidden="true">14</span><span class="cr-sign" aria-hidden="true">+</span><code class="cr-code"><span class="visually-hidden">Added: </span> <span class="k">const</span> discount = coupon ? Math.<span class="fn">round</span>(subtotal * coupon.rate) : <span class="n">0</span>;</code></div>
<div class="cr-line add" data-line><button class="btn cr-add" type="button" data-add-line="15"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 15</span></button><span class="cr-num" aria-hidden="true">15</span><span class="cr-sign" aria-hidden="true">+</span><code class="cr-code"><span class="visually-hidden">Added: </span> <span class="k">if</span> (discount > subtotal) <span class="k">return</span> <span class="n">0</span>;</code></div>
<div class="cr-thread" data-thread>
<div class="cr-thead"><p class="cr-thead-t"><b>Line 15</b> · Ines Duarte, Marco Ricci</p><span class="badge cr-pill"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg>Resolved</span><button class="btn cr-btn" type="button" data-resolve><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Resolve conversation</span></button></div>
<div data-body>
<ol class="list-unstyled cr-list" data-list><li class="cr-c"><span class="cr-av c1" aria-hidden="true">ID</span><div class="cr-cm"><p class="cr-who"><b>Ines Duarte</b> <span class="badge cr-role">Reviewer</span> <time datetime="2026-10-19T09:02">3 hours ago</time></p><p class="cr-ct">Can the discount ever be larger than the subtotal? If coupons are capped at 100%, this guard never runs.</p></div></li><li class="cr-c"><span class="cr-av c2" aria-hidden="true">MR</span><div class="cr-cm"><p class="cr-who"><b>Marco Ricci</b> <span class="badge cr-role">Author</span> <time datetime="2026-10-19T09:40">2 hours ago</time></p><p class="cr-ct">Fixed-amount coupons can: a €20 voucher on a €15 cart. I will add a test for that case in <code>total.test.js</code>.</p></div></li></ol>
<form class="cr-form"><label class="visually-hidden" for="cr-reply-15">Reply on line 15</label><textarea class="form-control cr-field" id="cr-reply-15" rows="2" placeholder="Reply…"></textarea><div class="cr-form-a"><button class="btn cr-primary" type="submit">Comment</button></div></form>
</div>
</div>
<div class="cr-line" data-line><button class="btn cr-add" type="button" data-add-line="16"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 16</span></button><span class="cr-num" aria-hidden="true">16</span><span class="cr-sign" aria-hidden="true"></span><code class="cr-code"> <span class="k">return</span> subtotal - discount;</code></div>
<div class="cr-line" data-line><button class="btn cr-add" type="button" data-add-line="17"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Comment on line 17</span></button><span class="cr-num" aria-hidden="true">17</span><span class="cr-sign" aria-hidden="true"></span><code class="cr-code">}</code></div>
</div>
<p class="visually-hidden" role="status" data-announce></p>
<template data-tpl="thread"><div class="cr-thread" data-thread>
<div class="cr-thead"><p class="cr-thead-t"><b>Line <span data-line-no></span></b> · New comment</p><span class="badge cr-pill"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg>Resolved</span><button class="btn cr-btn" type="button" data-resolve hidden data-after-first><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Resolve conversation</span></button></div>
<div data-body><ol class="list-unstyled cr-list" data-list></ol>
<form class="cr-form"><label class="visually-hidden">Comment on line <span data-line-no></span></label><textarea class="form-control cr-field" rows="2" placeholder="Leave a comment"></textarea><div class="cr-form-a"><button class="btn cr-btn" type="button" data-cancel>Cancel</button><button class="btn cr-primary" type="submit">Comment</button></div></form>
</div></div></template>
<template data-tpl="comment"><li class="cr-c"><span class="cr-av me" aria-hidden="true">YO</span><div class="cr-cm"><p class="cr-who"><b>You</b> <time >Just now</time></p><p class="cr-ct" data-text></p></div></li></template>
</section></div>
<div><span class="lbl">Document with margin annotations</span>
<section class="card cr-doc" data-review aria-label="Draft with comments">
<article class="cr-page">
<h2 class="cr-doc-h">Welcome to the support team</h2>
<p class="cr-doc-m">Draft · edited by Hana Ito on <time datetime="2026-10-18">18 October</time></p>
<p class="cr-p">Your first week is about listening. You will shadow two senior agents and read <mark class="cr-mark" id="cr-m1">every ticket tagged “billing” from the last 30 days</mark><sup><a class="cr-ref" href="#cr-n1"><span class="visually-hidden">Comment </span>1</a></sup> before you answer one yourself.</p>
<p class="cr-p">From week two you take live chats between 10:00 and 14:00. Aim to reply within <mark class="cr-mark" id="cr-m2">two minutes, and close the chat within ten</mark><sup><a class="cr-ref" href="#cr-n2"><span class="visually-hidden">Comment </span>2</a></sup>. If you are stuck, ask in the #support-help channel; nobody expects you to know the refund rules yet.</p>
</article>
<aside class="cr-notes" aria-label="Comments">
<h3 class="cr-notes-h">2 comments</h3>
<div class="cr-note" id="cr-n1" data-thread data-mark="cr-m1" tabindex="-1">
<div class="cr-note-t"><span class="cr-av c3" aria-hidden="true">LO</span><p class="cr-who"><b>1 · Laila Omar</b> <time datetime="2026-10-18T15:20">Yesterday</time></p></div>
<div data-body><p class="cr-quote">every ticket tagged “billing” from the last 30 days</p><p class="cr-ct">That is about 900 tickets. Could we pick the 40 that best show our refund policy instead?</p></div>
<div class="cr-note-f"><span class="badge cr-pill"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg>Resolved</span><button class="btn cr-btn" type="button" data-resolve><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Resolve conversation</span></button></div>
</div>
<div class="cr-note" id="cr-n2" data-thread data-mark="cr-m2" tabindex="-1">
<div class="cr-note-t"><span class="cr-av c2" aria-hidden="true">MR</span><p class="cr-who"><b>2 · Marco Ricci</b> <time datetime="2026-10-19T08:05">Today</time></p></div>
<div data-body><p class="cr-quote">two minutes, and close the chat within ten</p><p class="cr-ct">Ten minutes is our median, not a target. Suggest we drop the close time for new starters.</p></div>
<div class="cr-note-f"><span class="badge cr-pill"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg>Resolved</span><button class="btn cr-btn" type="button" data-resolve><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Resolve conversation</span></button></div>
</div>
</aside>
<p class="visually-hidden" role="status" data-announce></p>
</section></div>
/* Code review threads: + starts a thread on a line, Comment appends to it, Resolve collapses it (and clears the highlight
on a document passage), and hovering or focusing a margin comment highlights its passage. Frontend only. */
document.querySelectorAll('[data-review]').forEach(function (root) {
var live = root.querySelector('[data-announce]');
var tpl = function (n) { return root.querySelector('template[data-tpl="' + n + '"]').content.firstElementChild.cloneNode(true); };
root.addEventListener('click', function (e) {
var add = e.target.closest('[data-add-line]'), res = e.target.closest('[data-resolve]'), cancel = e.target.closest('[data-cancel]');
if (add) {
var line = add.closest('[data-line]'), next = line.nextElementSibling;
if (next && next.matches('[data-thread]')) { if (next.hasAttribute('data-resolved')) next.querySelector('[data-resolve]').click(); next.querySelector('textarea').focus(); return; }
var t = tpl('thread'), field = t.querySelector('textarea');
t.querySelectorAll('[data-line-no]').forEach(function (s) { s.textContent = add.dataset.addLine; });
field.id = 'cr-new-' + add.dataset.addLine; t.querySelector('label').htmlFor = field.id;
line.after(t); field.focus();
} else if (res) {
var th = res.closest('[data-thread]'), on = !th.hasAttribute('data-resolved');
th.toggleAttribute('data-resolved', on);
th.querySelector('[data-body]').hidden = on;
res.querySelector('[data-label]').textContent = on ? 'Unresolve' : 'Resolve conversation';
if (th.dataset.mark) document.getElementById(th.dataset.mark).toggleAttribute('data-resolved', on);
live.textContent = on ? 'Conversation resolved.' : 'Conversation reopened.';
} else if (cancel) {
var thr = cancel.closest('[data-thread]');
if (thr.querySelector('[data-list]').children.length) { thr.querySelector('textarea').value = ''; return; }
var btn = thr.previousElementSibling.querySelector('[data-add-line]');
thr.remove(); btn.focus();
}
});
root.addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target, field = form.querySelector('textarea'), text = field.value.trim(), th = form.closest('[data-thread]');
if (!text) { field.focus(); return; }
var c = tpl('comment');
c.querySelector('[data-text]').textContent = text;
c.querySelector('time').dateTime = new Date().toISOString();
th.querySelector('[data-list]').appendChild(c);
th.querySelectorAll('[data-after-first]').forEach(function (x) { x.hidden = false; });
var x = form.querySelector('[data-cancel]'); if (x) x.remove();
field.value = ''; field.placeholder = 'Reply…';
live.textContent = 'Comment added.';
});
root.querySelectorAll('[data-mark]').forEach(function (note) {
var mark = document.getElementById(note.dataset.mark);
var on = function () { mark.setAttribute('data-active', ''); }, off = function () { mark.removeAttribute('data-active'); };
note.addEventListener('mouseenter', on); note.addEventListener('mouseleave', off);
note.addEventListener('focusin', on); note.addEventListener('focusout', off);
});
});
/* Palette as theme colours. Line hover uses group-hover/l:, resolved threads use group-data-[resolved]/t:, and highlighted
passages react to data-active / data-resolved set by the script. */
@theme {
--color-cr-accent:#0969da; --color-cr-ink:#1f2328; --color-cr-muted:#59636e; --color-cr-line:#d1d9e0;
}
JavaScript
/* Code review threads: + starts a thread on a line, Comment appends to it, Resolve collapses it (and clears the highlight
on a document passage), and hovering or focusing a margin comment highlights its passage. Frontend only. */
document.querySelectorAll('[data-review]').forEach(function (root) {
var live = root.querySelector('[data-announce]');
var tpl = function (n) { return root.querySelector('template[data-tpl="' + n + '"]').content.firstElementChild.cloneNode(true); };
root.addEventListener('click', function (e) {
var add = e.target.closest('[data-add-line]'), res = e.target.closest('[data-resolve]'), cancel = e.target.closest('[data-cancel]');
if (add) {
var line = add.closest('[data-line]'), next = line.nextElementSibling;
if (next && next.matches('[data-thread]')) { if (next.hasAttribute('data-resolved')) next.querySelector('[data-resolve]').click(); next.querySelector('textarea').focus(); return; }
var t = tpl('thread'), field = t.querySelector('textarea');
t.querySelectorAll('[data-line-no]').forEach(function (s) { s.textContent = add.dataset.addLine; });
field.id = 'cr-new-' + add.dataset.addLine; t.querySelector('label').htmlFor = field.id;
line.after(t); field.focus();
} else if (res) {
var th = res.closest('[data-thread]'), on = !th.hasAttribute('data-resolved');
th.toggleAttribute('data-resolved', on);
th.querySelector('[data-body]').hidden = on;
res.querySelector('[data-label]').textContent = on ? 'Unresolve' : 'Resolve conversation';
if (th.dataset.mark) document.getElementById(th.dataset.mark).toggleAttribute('data-resolved', on);
live.textContent = on ? 'Conversation resolved.' : 'Conversation reopened.';
} else if (cancel) {
var thr = cancel.closest('[data-thread]');
if (thr.querySelector('[data-list]').children.length) { thr.querySelector('textarea').value = ''; return; }
var btn = thr.previousElementSibling.querySelector('[data-add-line]');
thr.remove(); btn.focus();
}
});
root.addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target, field = form.querySelector('textarea'), text = field.value.trim(), th = form.closest('[data-thread]');
if (!text) { field.focus(); return; }
var c = tpl('comment');
c.querySelector('[data-text]').textContent = text;
c.querySelector('time').dateTime = new Date().toISOString();
th.querySelector('[data-list]').appendChild(c);
th.querySelectorAll('[data-after-first]').forEach(function (x) { x.hidden = false; });
var x = form.querySelector('[data-cancel]'); if (x) x.remove();
field.value = ''; field.placeholder = 'Reply…';
live.textContent = 'Comment added.';
});
root.querySelectorAll('[data-mark]').forEach(function (note) {
var mark = document.getElementById(note.dataset.mark);
var on = function () { mark.setAttribute('data-active', ''); }, off = function () { mark.removeAttribute('data-active'); };
note.addEventListener('mouseenter', on); note.addEventListener('mouseleave', off);
note.addEventListener('focusin', on); note.addEventListener('focusout', off);
});
});
About this set
Comments pinned to a precise spot instead of the bottom of a page. The diff view shows a file header with added and removed line counts, numbered lines with green and red backgrounds, and a conversation pinned under line 15 between a reviewer and the author, with role tags, a reply box and a Resolve conversation button. Resolving collapses the conversation to its header with a Resolved tag, and Unresolve opens it again. Hovering a line, or tabbing to it, reveals a plus button that starts a new conversation on that line; Cancel removes an empty one and returns focus. The document view is for prose: highlighted passages in a draft are followed by numbered links to comments in a side column, each quoting the passage it refers to. Hovering or focusing a comment brightens its passage, and resolving it clears the highlight. On narrow screens the comment column moves below the text and code lines wrap instead of scrolling. Use the diff for developer tools and the document view for editorial review, contracts or design feedback.
What’s included
Diff lines with a conversation pinned under a specific line
A plus button on each line that starts a new conversation
Resolve and Unresolve with a Resolved tag
Highlighted passages linked to numbered margin comments
Comments that highlight their passage on hover or focus
Accessibility
Each plus button is named Comment on line n and appears whenever it has keyboard focus, added and removed lines carry hidden Added and Removed text, and line numbers and diff signs are hidden from screen readers. Reply fields are labelled, resolving or adding a comment is announced politely, and numbered links in the document jump to their comment. Every control has a visible focus outline, and the highlight transition stops under prefers-reduced-motion.
Customise it
Change --cr-accent, --cr-line and --cr-mono on .cr and .cr-doc; diff and highlight colours are plain values in the rules for .cr-line.add, .cr-line.del and .cr-mark. Bootstrap uses the same variables; in Tailwind edit the --color-cr-* theme colours.