Signed in, the combined diff says who you are commenting as, and a + sits beside the line under the pointer (or the line last tapped, on a phone). It opens a form under that line, for its new side; the post names the line as forge comment's <path>:<line> and carries the tip the diff shows. The sent comment waits under its line, as typed, until the rebuilt diff shows it there, and open forms and their drafts come back after the content is swapped. The diff names each file's path for this (data-path), on the combined diff only, and not for a deleted file, which has no new side.
- commit
- d114bf8d10ac6bba9b5b7d08afe1127966677dc3
- parent
- b8bdf201665854667618dc57b0b234b6447cc1f1
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 109s, signed by cat
- kind
- feat risk score 1.4 of 3, Jev's estimate
- review
- #11 Comment and approve from the web
ci attestation
forge-attestation 1 type ci commit d114bf8d10ac6bba9b5b7d08afe1127966677dc3 tree b8883d0a730386e95f72f43a9e910d14def69d19 script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a result pass exit 0 started 2026-10-19T00:56:05Z duration 109s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:e921906852c8554635a2905d6ee0e4a3d22b6a068a2a18e50bd2a669340671fc 347 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |d660256 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |d660256 pass in 1s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |2636054 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |2636054 fail in 1s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |7995828 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |7995828 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 487011b -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |8a711de running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |8a711de pass in 0s, signed |pushed main |c7e810b running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |c7e810b pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed aa4f1f9 -> /sites/dami/forge/guide/ |(node:40243) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:40393) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:40531) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:40675) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:41338) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | lib/ | 5 | +- |
| M | lib/ | 7 | +- |
| M | site/ | 259 | ++++++++++++++++++++++-------- |
| M | site/ | 17 | ++ |
| A | test/ | 26 | +++ |
@@ -470,14 +470,15 @@ export const markupFilters = new Set(['ago', 'date']);
470470 // Lazy renderers, registered as Nunjucks filters (setup() in render-page.ts).471471 export const filters = {472472 // `branch` links each changed file to its page on that branch, if present.473- // With `review`, its current line comments show under their lines. With473+ // With `review`, its current line comments show under their lines, and474+ // files carry their paths for new ones (site/assets/discuss.ts). With474475 // `mechanical` (review pages), files Jev judged mechanical are marked.475476 diffHtml(files: DiffFile[] | undefined, repo: RepoHead, prefix = '', branch: string | null = null, review: Review | null = null, mechanical = false): string {476477 const paths = branch ? new Set(registry.get(repo.slug)?.trees.get(branch)?.blobs.map((e) => e.path)) : null;477478 const blobHref = paths && branch ? (p: string) => (paths.has(p) ? repo.urls.blob(branch, p).href : null) : null;478479 const annotate = review ? inline(review, (c) => commentHtml(c, repo, review)) : null;479480 const dir = registry.get(repo.slug)?.dir;480- return html.diff(files ?? [], { prefix, blobHref, annotate, mechanical: mechanical && dir ? isMechanical(dir) : null });481+ return html.diff(files ?? [], { prefix, blobHref, annotate, mechanical: mechanical && dir ? isMechanical(dir) : null, paths: !!review });481482 },482483 // Review descriptions (one line in the log, "\n" for line breaks) and483484 // comments are markdown, sanitized like READMEs; links resolve on the branch.@@ -73,7 +73,9 @@ type Annotate = ((path: string, newLine: number) => string | null) | null;
7373 7474 // `mechanical(file)` says Jev judged the change mechanical (a rename,7575 // lockfile, generated file...): it is labelled, and diffs.ts folds it.76-export function diff(files: DiffFile[], { prefix = '', blobHref, annotate, mechanical }: { prefix?: string; blobHref?: ((path: string) => string | null) | null; annotate?: Annotate; mechanical?: ((f: DiffFile) => boolean) | null } = {}): string {76+// With `paths`, each file that still exists carries its path77+// (data-path), for line comments from the page (discuss.ts).78+export function diff(files: DiffFile[], { prefix = '', blobHref, annotate, mechanical, paths = false }: { prefix?: string; blobHref?: ((path: string) => string | null) | null; annotate?: Annotate; mechanical?: ((f: DiffFile) => boolean) | null; paths?: boolean } = {}): string {7779 let budget = MAX_PAGE_LINES;7880 return files.map((f, i) => {7981 const id = `${prefix}f${i}`;@@ -84,7 +86,8 @@ export function diff(files: DiffFile[], { prefix = '', blobHref, annotate, mecha
8486 ` <span class="counts"><span class="i">+${f.add}</span> <span class="d">-${f.del}</span></span>` +8587 (mech ? ' <span class="jev-mech" title="Likely mechanical (Jev): a rename, reformatting, generated or vendored code, a lockfile, a version bump or a search-and-replace">mechanical</span>' : '') +8688 (fileHref ? ` <a class="view" href="${fileHref}">view file</a>` : '') + '</header>';87- const wrap = (body: string): string => `<section class="file jump" id="${id}"${mech ? ' data-mechanical' : ''}>${head}${body}</section>`;89+ const at = paths && f.status !== 'D' ? ` data-path="${esc(f.path)}"` : '';90+ const wrap = (body: string): string => `<section class="file jump" id="${id}"${at}${mech ? ' data-mechanical' : ''}>${head}${body}</section>`;8891 if (f.omitted) return wrap('<p class="note">Diff not shown: this commit changes too many lines.</p>');8992 if (f.binary) return wrap('<p class="note">Binary file.</p>');9093 if (!f.hunks.length) return wrap(`<p class="note">${f.mode ? 'File mode ' + esc(f.mode) : 'No content changes.'}</p>`);@@ -1,12 +1,14 @@
11 // Commenting and approving from a review page, signed in. Pages are static22 // and the same for everyone, so the comment form and the Approve button33 // (discussion.njk) start hidden; this asks the private server who is4-// signed in (/login/whoami, never cached) and shows them to a user. Posts5-// go to /<owner>/<repo>/reviews/<id>/comment and .../approve with the6-// session's CSRF token and the tip the page shows, and lib/private-server.ts7-// runs forge comment's and forge approve's own code for them. Until the8-// rebuilt page comes in (live.ts swaps it in once the build is done), a9-// sent comment shows as pending, as typed: its markdown is rendered by the4+// signed in (/login/whoami, never cached) and shows them to a user. On the5+// combined diff, a + beside the line under the pointer (or the line last6+// clicked) opens a form for a comment on that line's new side. Posts go to7+// /<owner>/<repo>/reviews/<id>/comment and .../approve with the session's8+// CSRF token and the tip the page shows, and lib/private-server.ts runs9+// forge comment's and forge approve's own code for them. Until the rebuilt10+// page comes in (live.ts swaps it in once the build is done), a sent11+// comment shows as pending, as typed: its markdown is rendered by the1012 // build, not here. Drafts outlast that swap, and reloads (sessionStorage).1113 (() => {1214 interface Me {@@ -17,10 +19,14 @@
1719 kind: 'comment' | 'approve';1820 n?: number;1921 text?: string;22+ // path:n, for a line comment.23+ line?: string;2024 }2125 let me: Me | null = null;2226 // What was sent from this page and isn't on it yet.2327 let sent: Sent[] = [];28+ // Line comment forms open on this page, by path:n.29+ const opened = new Set<string>();2430 2531 const draftKey = (k: string): string => `forge.draft ${location.pathname} ${k}`;2632 const draft = (k: string, text?: string): string => {@@ -42,6 +48,7 @@
4248 if (!r.ok) throw new Refused(data.error ?? (r.status === 404 ? 'This review is not there any more.' : `The forge said HTTP ${r.status}.`));4349 return { message: data.message ?? '', comment: data.comment };4450 }51+ const failed = (err: unknown): string => (err instanceof Refused ? err.message : "Couldn't reach the forge; your text is still here.");4552 4653 const say = (form: Element | null, text: string, bad = false): void => {4754 const s = form?.querySelector<HTMLElement>('.reply-status');@@ -53,38 +60,120 @@
5360 for (const b of form.querySelectorAll<HTMLButtonElement>('button')) b.disabled = on;5461 };5562 63+ // ------------------------------------------------ lines of the combined diff64+65+ const lines = (): HTMLElement | null => document.getElementById('line-comments');66+ const newLine = (row: Element): string => row.querySelectorAll('.ln')[1]?.textContent ?? '';67+ // path:n of a row on the new side, or null.68+ const whereOf = (row: Element): string | null => {69+ const path = row.closest<HTMLElement>('section.file[data-path]')?.dataset.path;70+ const n = newLine(row);71+ return path && n ? `${path}:${n}` : null;72+ };73+ function rowOf(where: string): HTMLElement | null {74+ const i = where.lastIndexOf(':');75+ const file = [...document.querySelectorAll<HTMLElement>('section.file[data-path]')].find((f) => f.dataset.path === where.slice(0, i));76+ return [...(file?.querySelectorAll<HTMLElement>('.code a.l') ?? [])].find((l) => newLine(l) === where.slice(i + 1)) ?? null;77+ }78+ // Under a row, after what is under it already.79+ function below(row: HTMLElement, el: HTMLElement): void {80+ let at: Element = row;81+ while (at.nextElementSibling?.classList.contains('cmts')) at = at.nextElementSibling;82+ at.after(el);83+ }84+85+ // The + of the line under the pointer, or tapped last.86+ const plus = document.createElement('button');87+ plus.type = 'button';88+ plus.className = 'addline';89+ plus.textContent = '+';90+ function place(row: HTMLElement): void {91+ const where = me && lines() ? whereOf(row) : null;92+ const rows = row.parentElement;93+ const code = row.closest('.code');94+ if (!where || !rows || !code) return;95+ plus.dataset.where = where;96+ plus.title = `Comment on ${where}`;97+ plus.setAttribute('aria-label', plus.title);98+ if (plus.parentElement !== rows) rows.append(plus);99+ plus.style.top = `${row.offsetTop + 1}px`;100+ plus.style.left = `${code.scrollLeft + 2}px`;101+ }102+103+ function openForm(where: string, focus: boolean): void {104+ const row = rowOf(where);105+ if (!row || !me) return;106+ const had = [...document.querySelectorAll<HTMLElement>('[data-line-form]')].find((x) => x.dataset.lineForm === where);107+ if (had) {108+ if (focus) had.querySelector('textarea')?.focus();109+ return;110+ }111+ opened.add(where);112+ const box = document.createElement('div');113+ box.className = 'cmts';114+ box.dataset.lineForm = where;115+ box.innerHTML = '<form class="linecmt"><div class="where"></div><textarea name="body" maxlength="32768" placeholder="Comment on this line. Markdown is fine."></textarea>' +116+ '<div class="reply-bar"><span class="reply-status" role="status"></span><span class="grow"></span><button type="button" data-cancel>Cancel</button><button type="submit" class="go">Comment</button></div></form>';117+ box.querySelector('.where')!.textContent = `${where}, as ${me.user}`;118+ const text = box.querySelector('textarea')!;119+ text.setAttribute('aria-label', `Comment on ${where}`);120+ text.value = draft(`line ${where}`);121+ below(row, box);122+ if (focus) text.focus();123+ }124+ const closeForm = (box: HTMLElement): void => {125+ opened.delete(box.dataset.lineForm ?? '');126+ box.remove();127+ };128+129+ // ------------------------------------------------ sent, not shown yet130+56131 // Is it on the page now? Then it is no longer pending.57132 const shown = (s: Sent): boolean =>58133 s.kind === 'comment' ? !!document.getElementById(`comment-${s.n}`) : !!me && [...document.querySelectorAll<HTMLElement>('ul.approvals li.current')].some((li) => li.dataset.user === me!.user);59134 60- // Pending comments at the end of the discussion, and the approval in the61- // form's status line, drawn again after each swap of the content.135+ const pendingHtml = '<div class="comment-head"><b></b> <span class="date">sent, shown here once the page is rebuilt</span></div><div class="text"></div>';136+ function pendingBox(tag: string, cls: string, s: Sent): HTMLElement {137+ const el = document.createElement(tag);138+ el.className = cls;139+ el.setAttribute('data-sent', '');140+ el.innerHTML = pendingHtml;141+ el.querySelector('b')!.textContent = me?.user ?? '';142+ el.querySelector('.text')!.textContent = s.text ?? '';143+ return el;144+ }145+146+ // Pending comments at the end of the discussion or under their line, and147+ // the approval in the form's status line, drawn again after each swap.62148 function drawSent(): void {63149 sent = sent.filter((s) => !shown(s));64150 for (const el of document.querySelectorAll('[data-sent]')) el.remove();65151 const form = document.getElementById('reply');66- if (!form || !me) return;67152 for (const s of sent) {68- if (s.kind === 'approve') {69- say(form, 'Approved; it shows here once the page is rebuilt.');70- continue;71- }72- let list = document.querySelector('ol.comments');73- if (!list) {74- list = document.createElement('ol');75- list.className = 'comments';76- list.setAttribute('data-sent', '');77- form.before(list);153+ if (s.kind === 'approve') say(form, 'Approved; it shows here once the page is rebuilt.');154+ else if (s.line) {155+ const row = rowOf(s.line);156+ if (!row) continue;157+ const box = document.createElement('div');158+ box.className = 'cmts';159+ box.setAttribute('data-sent', '');160+ const c = pendingBox('div', 'cmt pending', s);161+ c.removeAttribute('data-sent');162+ c.querySelector('.comment-head')!.className = 'cmt-head';163+ box.append(c);164+ below(row, box);165+ } else if (form) {166+ let list = document.querySelector('ol.comments');167+ if (!list) {168+ list = document.createElement('ol');169+ list.className = 'comments';170+ list.setAttribute('data-sent', '');171+ form.before(list);172+ }173+ list.append(pendingBox('li', 'comment pending', s));174+ document.getElementById('no-comments')?.setAttribute('hidden', '');78175 }79- const li = document.createElement('li');80- li.className = 'comment pending';81- li.setAttribute('data-sent', '');82- li.innerHTML = '<div class="comment-head"><b></b> <span class="date">sent, shown here once the page is rebuilt</span></div><div class="text"></div>';83- li.querySelector('b')!.textContent = me.user;84- li.querySelector('.text')!.textContent = s.text ?? '';85- list.append(li);86176 }87- if (document.querySelector('ol.comments [data-sent]')) document.getElementById('no-comments')?.setAttribute('hidden', '');88177 }89178 90179 // live.ts brings the rebuilt page when the build's event arrives. Should@@ -99,9 +188,12 @@
99188 }, wait);100189 }101190 191+ // ------------------------------------------------ the page, signed in192+102193 function setup(): void {194+ if (!me) return;103195 const form = document.getElementById('reply') as HTMLFormElement | null;104- if (form && me) {196+ if (form) {105197 form.hidden = false;106198 form.querySelector('.who')!.textContent = `as ${me.user}`;107199 const text = form.querySelector('textarea')!;@@ -114,75 +206,112 @@
114206 approve.title = 'You approved this tree';115207 }116208 }209+ const hint = lines();210+ if (hint) {211+ hint.hidden = false;212+ hint.textContent = `Commenting as ${me.user}: the + beside a line comments on it.`;213+ document.body.classList.add('commenting');214+ for (const where of opened) openForm(where, false);215+ }117216 drawSent();118217 }119218 219+ document.addEventListener('mouseover', (e) => {220+ const row = (e.target as Element).closest<HTMLElement>('section.file[data-path] .code a.l');221+ if (row) place(row);222+ });223+224+ document.addEventListener('click', async (e) => {225+ const t = e.target as Element;226+ const row = t.closest<HTMLElement>('section.file[data-path] .code a.l');227+ // A tap on a line (no pointer to hover with) puts the + there.228+ if (row) return place(row);229+ if (t.closest('.addline')) {230+ e.preventDefault();231+ return openForm(plus.dataset.where ?? '', true);232+ }233+ const cancel = t.closest('[data-line-form] [data-cancel]')?.closest<HTMLElement>('[data-line-form]');234+ if (cancel) {235+ draft(`line ${cancel.dataset.lineForm}`, '');236+ return closeForm(cancel);237+ }238+ const b = t.closest<HTMLButtonElement>('form#reply [data-approve]');239+ const form = b?.closest<HTMLFormElement>('form');240+ if (!b || !form || !me) return;241+ busy(form, true);242+ say(form, 'Approving…');243+ try {244+ const r = await send(form.dataset.post + 'approve', { at: form.dataset.at ?? '' });245+ // Approved before: nothing changes, so nothing to wait for.246+ if (/^you already approved/.test(r.message)) say(form, 'You approved this tree already.');247+ else {248+ sent.push({ kind: 'approve' });249+ drawSent();250+ watch();251+ }252+ busy(form, false);253+ b.textContent = 'Approved';254+ b.disabled = true;255+ } catch (err) {256+ say(form, failed(err), true);257+ busy(form, false);258+ }259+ });260+261+ // A line's + follows the code when it scrolls sideways.262+ document.addEventListener('scroll', (e) => {263+ const code = e.target as Element;264+ if (code instanceof Element && code.matches('.code') && code.contains(plus)) plus.style.left = `${code.scrollLeft + 2}px`;265+ }, true);266+120267 document.addEventListener('input', (e) => {121268 const t = e.target as HTMLTextAreaElement;122- if (t.closest('form#reply')) draft('review', t.value);269+ const box = t.closest<HTMLElement>('[data-line-form]');270+ if (box) draft(`line ${box.dataset.lineForm}`, t.value);271+ else if (t.closest('form#reply')) draft('review', t.value);123272 });124273 125274 // Ctrl or Cmd and Enter sends, as in most comment boxes.126275 document.addEventListener('keydown', (e) => {127276 const t = e.target as HTMLElement;128- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && t.matches('form#reply textarea')) {277+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && t.matches('form#reply textarea, form.linecmt textarea')) {129278 e.preventDefault();130279 t.closest('form')!.requestSubmit();131280 }132281 });133282 134283 document.addEventListener('submit', async (e) => {135- const form = (e.target as Element).closest<HTMLFormElement>('form#reply');136- if (!form || !me) return;284+ const form = (e.target as Element).closest<HTMLFormElement>('form#reply, form.linecmt');285+ const box = form?.closest<HTMLElement>('[data-line-form]');286+ const post = box ? lines()?.dataset : form?.dataset;287+ if (!form || !post || !me) return;137288 e.preventDefault();138- const box = form.querySelector('textarea')!;139- const text = box.value;289+ const field = form.querySelector('textarea')!;290+ const text = field.value;140291 if (!text.trim()) return say(form, 'Write a comment first.', true);292+ const where = box?.dataset.lineForm;141293 busy(form, true);142294 say(form, 'Sending…');143295 try {144- const r = await send(form.dataset.post + 'comment', { body: text, at: form.dataset.at ?? '' });145- box.value = '';146- draft('review', '');147- sent.push({ kind: 'comment', n: r.comment, text });296+ const r = await send(post.post + 'comment', { body: text, at: post.at ?? '', ...(where ? { line: where } : {}) });297+ field.value = '';298+ draft(where ? `line ${where}` : 'review', '');299+ if (box) closeForm(box);300+ sent.push({ kind: 'comment', n: r.comment, text, line: where });148301 // forge comment's own note, when the review moved on since this page was built.149- say(form, r.message.split('\n').slice(1).join(' '));302+ say(document.getElementById('reply'), r.message.split('\n').slice(1).join(' '));150303 drawSent();151304 watch();152305 } catch (err) {153- say(form, err instanceof Refused ? err.message : "Couldn't reach the forge; your text is still here.", true);306+ say(form, failed(err), true);154307 } finally {155308 busy(form, false);156309 }157310 });158311 159- document.addEventListener('click', async (e) => {160- const b = (e.target as Element).closest<HTMLButtonElement>('form#reply [data-approve]');161- const form = b?.closest<HTMLFormElement>('form');162- if (!b || !form || !me) return;163- busy(form, true);164- say(form, 'Approving…');165- try {166- const r = await send(form.dataset.post + 'approve', { at: form.dataset.at ?? '' });167- // Approved before: nothing changes, so nothing to wait for.168- if (/^you already approved/.test(r.message)) say(form, 'You approved this tree already.');169- else {170- sent.push({ kind: 'approve' });171- drawSent();172- watch();173- }174- b.textContent = 'Approved';175- busy(form, false);176- b.disabled = true;177- } catch (err) {178- say(form, err instanceof Refused ? err.message : "Couldn't reach the forge.", true);179- busy(form, false);180- }181- });182-183312 // Signed out, or a server without the private server (npm run dev): the184313 // controls stay hidden.185- if (!document.getElementById('reply')) return;314+ if (!document.getElementById('reply') && !lines()) return;186315 fetch('/login/whoami', { cache: 'no-store', credentials: 'same-origin' })187316 .then((r) => (r.ok ? r.json() : null))188317 .then((w: { user?: string | null; csrf?: string } | null) => {@@ -7,6 +7,7 @@ pagination:
77 {% set rv = p.review %}88 {% block head %}{{ m.reviewMeta(rv, p.repo, p.title, p.repo.urls.reviewDiff(rv.id).href) }}{% endblock %}99 {% block scripts %}<script src="{{ assets.diffs }}" defer></script>10+<script src="{{ assets.discuss }}" defer></script>1011 {% endblock %}1112 {% block content %}1213 {% css %}@@ -21,11 +22,27 @@ pagination:
2122 .code:has(.cmts) { container-type: inline-size; }2223 .code .cmts { position: sticky; left: 0; width: 100cqw; padding: 8px 10px; background: var(--subtle); border-block: 1px solid var(--border); font: 15px/1.5 var(--sans); white-space: normal; }2324 .cmt + .cmt { margin-top: 8px; }25+/* A new line comment, signed in (discuss.ts): the + on the line under the26+ pointer (or the line last clicked) opens a form under it. */27+.commenting .code.diff .rows { position: relative; }28+.addline { position: absolute; z-index: 2; width: 20px; height: 17px; padding: 0; font: 600 13px/15px var(--sans); text-align: center; color: #fff; background: var(--accent); border-color: var(--accent); border-radius: 4px; }29+.addline:hover { background: var(--accent); filter: brightness(1.1); }30+.cmts .where { font: 12px/1.6 var(--mono); color: var(--muted); margin-bottom: 4px; overflow-wrap: anywhere; }31+.cmts textarea { display: block; width: 100%; min-height: 5em; resize: vertical; font: 14px/1.5 var(--sans); color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; }32+.cmts textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }33+.cmts .reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-top: 8px; font-size: 0.92em; }34+.cmts .reply-bar .grow { flex: 1; }35+.cmts .reply-bar button.go { background: var(--accent); border-color: var(--accent); color: #fff; }36+.cmts .reply-bar button:disabled { opacity: 0.6; cursor: default; }37+.cmts .reply-status.bad { color: var(--fail); white-space: pre-line; }38+.cmt.pending { border-style: dashed; }39+.cmt.pending .text { padding: 8px 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }2440 {% endcss %}2541 <div class="meta-head">2642 <h1><a href="{{ p.repo.urls.review(rv.id).href }}" data-key="u">{{ rv.title }}</a> <span class="desc">#{{ rv.id }}</span></h1>2743 <div class="byline">Combined diff of {{ rv.layer.length | plural('commit') }} on <span class="mono">{{ rv.branch }}</span></div>2844 </div>45+<p class="desc" id="line-comments" data-post="{{ p.repo.urls.review(rv.id).href }}" data-at="{{ rv.tip }}" hidden></p>2946 <div class="changes"><span class="tools"><span class="count" data-viewed="{{ p.repo.slug }}" hidden></span><button type="button" data-wrap>Wrap lines</button></span>3047 {{ rv.combined | diffstatHtml | safe }}3148 </div>@@ -0,0 +1,26 @@
1+# The combined diff names each file's path (data-path) for line comments2+# from the page, which discuss.js offers to a user signed in: a hidden hint3+# that knows where to post and the tip the diff shows. A deleted file has4+# no new side to comment on, so no path. Diffs elsewhere carry none.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/dami"9+at "$T/w/dami" commit "first" greet.sh10+at "$T/w/dami" commit "old" old.txt11+ok push_as dami "$T/w/dami" origin main12+at "$T/w/dami" git switch -q -c dami/feature13+at "$T/w/dami" commit "feature" greet.sh14+at "$T/w/dami" git rm -q old.txt15+at "$T/w/dami" git commit -q -m "drop old"16+ok push_as dami "$T/w/dami" origin dami/feature17+build18+tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)19+20+d=$FORGE_WWW/dami/greet/reviews/1/diff/index.html21+has "$d" "<p class=\"desc\" id=\"line-comments\" data-post=\"/dami/greet/reviews/1/\" data-at=\"$tip\" hidden></p>"22+has "$d" '<section class="file jump" id="f0" data-path="greet.sh">'23+has "$d" '<section class="file jump" id="f1"><header'24+has "$d" '<script src="/assets/discuss\.[A-Za-z0-9_-]{10}\.js" defer>'25+lacks "$FORGE_WWW/dami/greet/reviews/1/index.html" 'data-path='26+lacks "$FORGE_WWW/dami/greet/commit/$tip/index.html" 'data-path='