diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -67,6 +67,12 @@ sort by a column again.
   fetch. A stale row is dimmed and gets an amber dot; a failed row gets a red
   dot, and its error appears in the banner. The tray tooltip also says
   whether the data is stale.
+- **Live updates**: the repo being fetched has a pulsing blue dot. When its
+  fetch lands, its row updates in place, and each cell whose value changed,
+  other than Fetched, is highlighted for 2.5 s. While the pointer is over the
+  rows, they keep their places; they move to their sorted order when the
+  pointer leaves or the window loses focus. Sorting, changing the window and
+  dragging a row reorder the table at once.
 
 ### How issues are scored
 
diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts
--- a/ui/src/app.test.ts
+++ b/ui/src/app.test.ts
@@ -281,6 +281,332 @@ test('an empty config says so', async () => {
   assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);
 });
 
+// ───────────────────────────── live updates ─────────────────────────────
+
+/** The default snapshot with some repos' stats, entry fields or snapshot fields changed. */
+function changed(stats: Record<string, Partial<RepoStats>>, entries: Record<string, Partial<RepoEntry>> = {}, extra: Partial<Snapshot> = {}): Snapshot {
+  const s = snapshot();
+  s.repos = s.repos.map((e) => {
+    const name = e.repo.slice(2);
+    return { ...e, ...entries[name], stats: e.stats && { ...e.stats, ...stats[name] } };
+  });
+  return { ...s, ...extra };
+}
+
+const hover = (h: Harness, type: 'pointerenter' | 'pointerleave') =>
+  h.doc.getElementById('tbody')!.dispatchEvent(new h.win.PointerEvent(type) as unknown as Event);
+
+const column = (key: string): number => COLUMNS.findIndex((c) => c.key === key);
+const FETCHED = column('fetched');
+const URGENCY = column('urgency');
+const PRS = column('open_prs');
+
+test('a snapshot updates rows in place', async () => {
+  const h = await setup();
+  const alpha = h.row('alpha');
+  const prs = alpha.cells[PRS]!;
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));
+  // assert.ok rather than assert.equal: a failing equal prints both elements, which can take minutes.
+  assert.ok(h.row('alpha') === alpha, 'the same row');
+  assert.ok(alpha.cells[PRS] === prs, 'the same cell');
+  assert.equal(prs.textContent, '9');
+});
+
+test('a snapshot keeps the nodes of cells whose content did not change', async () => {
+  const h = await setup();
+  const prs = () => h.row('alpha').cells[PRS]!.firstChild as HTMLElement;
+  const link = prs();
+  const name = h.row('alpha').cells[0]!.firstChild;
+  h.emit('craft:snapshot', changed({ beta: { open_prs: 9 } }, {}, { fetching: 'o/alpha' }));
+  assert.ok(prs() === link, 'the same link element');
+  assert.ok(h.row('alpha').cells[0]!.firstChild === name, 'the same repo name');
+  link.click();
+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/pulls');
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));
+  assert.ok(prs() !== link, 'a new value gets a new element');
+  assert.equal(prs().textContent, '9');
+});
+
+test('a fetch keeps the repo name and its link, and updates the tooltip', async () => {
+  const h = await setup();
+  const name = () => h.row('alpha').cells[0]!.firstChild as HTMLElement;
+  const before = name();
+  const link = before.querySelector('.link');
+  const tip = before.title;
+  h.emit('craft:snapshot', changed({}, { alpha: { fetched_at: '2026-01-02T03:04:05Z' } }));
+  assert.ok(name() === before && name().querySelector('.link') === link, 'the same name and link');
+  assert.notEqual(before.title, tip);
+  assert.match(before.title, /^Fetched /);
+});
+
+test('a clock render keeps an activity cell whose tooltip has aged', async (t) => {
+  t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() });
+  const built = new Date(Date.now() - 3600e3 + 5e3).toISOString();
+  const act = { commits: 3, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 };
+  const h = await setup({ snap: changed({ alpha: { latest_build_at: built, recent: { release: act } } }), storage: { window: 'release' } });
+  const commits = () => h.row('alpha').cells[column('recent_commits')]!.firstChild as HTMLElement;
+  const before = commits();
+  assert.match(before.title, /\(59m ago\)$/);
+  t.mock.timers.tick(15_000);
+  assert.ok(commits() === before, 'the same element');
+  assert.match(before.title, /\(1h ago\)$/);
+});
+
+test('a link whose address changes is replaced even when its text is the same', async () => {
+  const lag = (from_sha: string) => ({ since_build: { commits: 5, basis: 'by_date' as const, from_sha } });
+  const h = await setup({ snap: changed({ alpha: lag('aaaaaaa1') }) });
+  const unreleased = () => h.row('alpha').cells[column('commits_since_build')]!.querySelector<HTMLElement>('.link')!;
+  const before = unreleased();
+  h.emit('craft:snapshot', changed({ alpha: lag('bbbbbbb2') }));
+  assert.ok(unreleased() !== before, 'a new link');
+  unreleased().click();
+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/compare/bbbbbbb2...HEAD');
+});
+
+test('cells carry their tooltips, links and marks, and the footer its totals', async () => {
+  const old = new Date(Date.now() - 20 * 86400e3).toISOString();
+  const act = { commits: 3, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 };
+  const h = await setup({ snap: snapshot([
+    repo('alpha', {
+      since_build: { commits: 4, basis: 'exact', from_sha: 'aaaaaaa1bbbb' }, latest_build_prerelease: true,
+      oldest_open_pr_at: old, recent: { release: act },
+    }),
+    repo('beta', { since_build: { commits: 0, basis: 'by_date', from_sha: 'ccccccc3dddd' } }, { error: 'boom' }),
+    repo('gamma', { since_build: { commits: 2, basis: 'by_date', from_sha: null } }, { fetched_at: null }),
+    repo('delta', null, { inaccessible: true }),
+    repo('eps', { latest_build: null }),
+  ]), storage: { window: 'release' } });
+  const at = (name: string, key: string) => h.row(name).cells[column(key)]!;
+  const tip = (name: string, key: string) => (at(name, key).firstChild as HTMLElement).title;
+
+  assert.match(tip('alpha', 'name'), /^Fetched /);
+  assert.equal(tip('beta', 'name'), 'Last attempt failed: boom');
+  assert.equal(tip('gamma', 'name'), 'Not fetched yet');
+  assert.equal(tip('delta', 'name'), 'Not accessible (private, or not found)');
+  assert.equal(at('delta', 'urgency').textContent, '');
+
+  assert.equal(tip('alpha', 'commits_since_build'), '4 commits on main since v1.0.0 (aaaaaaa)');
+  at('alpha', 'commits_since_build').querySelector<HTMLElement>('.link')!.click();
+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/compare/v1.0.0...HEAD');
+  assert.equal(tip('beta', 'commits_since_build'),
+    'v1.0.0’s commit shares no history with main: ~0 commits on main are newer than it, counted from ccccccc');
+  assert.ok(at('beta', 'commits_since_build').querySelector('.link.zero'), 'a zero count is a dimmed link');
+  assert.equal(tip('gamma', 'commits_since_build'), 'v1.0.0’s commit shares no history with main: ~2 commits on main are newer than it');
+  assert.ok(!at('gamma', 'commits_since_build').querySelector('.link'), 'no commit to compare from');
+  assert.equal(at('gamma', 'commits_since_build').textContent, '~2');
+  assert.ok(at('eps', 'commits_since_build').querySelector('.muted'));
+
+  assert.equal(at('alpha', 'latest_build').textContent, 'v1.0.0PRE');
+  assert.ok(at('alpha', 'latest_build').querySelector('.link + .pre'));
+  assert.equal(at('beta', 'latest_build').querySelector('.pre'), null);
+  assert.equal(at('eps', 'latest_build').querySelector('.muted')!.textContent, '—');
+
+  assert.ok(at('alpha', 'oldest_open_pr_at').querySelector('.old'), 'an open PR older than 14 days');
+  assert.equal(at('beta', 'oldest_open_pr_at').querySelector('.old'), null);
+  assert.ok(tip('beta', 'oldest_open_pr_at'));
+
+  assert.match(tip('alpha', 'recent_commits'), /^Since v1\.0\.0, published .+ \(now ago\)$/);
+  assert.ok(at('alpha', 'recent_commits').querySelector('.active'));
+  assert.equal(tip('beta', 'recent_commits'), 'Not fetched yet for this window');
+  assert.equal(tip('eps', 'recent_commits'), 'No release yet');
+
+  const foot = [...h.doc.querySelectorAll('tfoot td')];
+  assert.equal(foot[0]!.textContent, '5 repos');
+  assert.equal(foot[PRS]!.textContent, '4');
+  assert.equal(foot[column('oldest_open_pr_at')]!.textContent, '');
+});
+
+test('rows move to their sorted place without being rebuilt', async () => {
+  const h = await setup();
+  const before = ['alpha', 'beta', 'gamma'].map((n) => h.row(n));
+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));
+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
+  assert.ok(['alpha', 'beta', 'gamma'].every((n, i) => h.row(n) === before[i]), 'the same rows');
+});
+
+test('the Fetched cell keeps its refresh button while the clock and fetches change its age', async (t) => {
+  // The 15 s clock is created by start(), so setInterval is mocked before setup(); setTimeout stays real for tick().
+  t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() });
+  const fresh = () => new Date().toISOString();
+  const h = await setup({ snap: changed({}, { gamma: { fetched_at: fresh() } }) });
+  const cell = () => h.row('gamma').cells[FETCHED]!;
+  const button = cell().querySelector<HTMLButtonElement>('.row-refresh')!;
+  assert.equal(cell().textContent, 'now');
+  t.mock.timers.tick(15_000);
+  assert.equal(cell().textContent, '15s', 'the clock aged the text');
+  assert.ok(cell().querySelector('.row-refresh') === button, 'the same button after a clock render');
+  h.emit('craft:snapshot', changed({}, { gamma: { fetched_at: fresh() } }, { fetching: 'o/gamma' }));
+  assert.equal(cell().textContent, 'now');
+  assert.ok(cell().querySelector('.row-refresh') === button, 'the same button after a fetch');
+  assert.ok(button.classList.contains('spinning'));
+  assert.ok(!cell().classList.contains('changed'), 'a new fetch time does not flash');
+  h.emit('craft:snapshot', changed({}, { gamma: { fetched_at: fresh() } }));
+  assert.ok(!button.classList.contains('spinning'), 'done');
+  assert.equal(button.title, 'Refresh only gamma');
+});
+
+test('the row of the repo being fetched is marked', async () => {
+  const h = await setup();
+  h.emit('craft:snapshot', changed({}, {}, { fetching: 'o/alpha' }));
+  assert.ok(h.row('alpha').classList.contains('fetching'));
+  assert.ok(!h.row('beta').classList.contains('fetching'));
+  h.emit('craft:snapshot', snapshot());
+  assert.ok(!h.doc.querySelector('tr.fetching'));
+});
+
+test('rows do not move under the pointer', async () => {
+  const h = await setup();
+  hover(h, 'pointerenter');
+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'the order waits');
+  assert.equal(h.row('gamma').cells[URGENCY]!.textContent, '400', 'the values do not');
+  hover(h, 'pointerleave');
+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
+});
+
+test('a hover ends when the window loses focus, and the held order applies', async () => {
+  const h = await setup();
+  hover(h, 'pointerenter');
+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'the order waits');
+  h.win.dispatchEvent(new h.win.Event('blur'));
+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
+  h.emit('craft:snapshot', changed({ alpha: { urgency: 500 } }));
+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'the next snapshot reorders at once');
+  hover(h, 'pointerenter');
+  h.emit('craft:snapshot', changed({ gamma: { urgency: 600 } }));
+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);
+  h.emit('craft:focus', true);
+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'gaining focus changes nothing');
+  h.emit('craft:focus', false);
+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha'], 'the host’s focus event too');
+  hover(h, 'pointerenter');
+  h.emit('craft:snapshot', changed({ beta: { urgency: 700 } }));
+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
+  Object.defineProperty(h.doc, 'hidden', { value: true, configurable: true });
+  h.doc.dispatchEvent(new h.win.Event('visibilitychange') as unknown as Event);
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'hiding the window');
+});
+
+test('a header click reorders at once even while hovering', async () => {
+  const h = await setup();
+  hover(h, 'pointerenter');
+  header(h, 'Repo').click();
+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);
+  dragRow(h, 'alpha', 2.5 * ROW_HEIGHT);
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'a drop too');
+  assert.ok(!h.doc.querySelector('tr.dragging'), 'the dropped row is no longer marked as dragged');
+  assert.deepEqual(stored(h, 'order'), ['o/beta', 'o/gamma', 'o/alpha']);
+  h.emit('craft:snapshot', changed({ alpha: { urgency: 500 } }));
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'a snapshot keeps the manual order');
+});
+
+test('repos that leave the config lose their rows', async () => {
+  const h = await setup();
+  const beta = h.row('beta');
+  h.emit('craft:snapshot', snapshot([repo('beta', {}), repo('delta', {})]));
+  assert.deepEqual(h.rows().sort(), ['beta', 'delta']);
+  assert.ok(h.row('beta') === beta, 'the same row');
+  h.emit('craft:snapshot', snapshot([]));
+  assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);
+  h.emit('craft:snapshot', snapshot());
+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);
+  assert.doesNotMatch(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);
+});
+
+// setTimeout is mocked only after setup(), whose tick() needs the real one;
+// setInterval (the 15 s clock) stays real.
+const lit = (h: Harness) => [...h.doc.querySelectorAll('td.changed')];
+
+test('a changed value flashes its cell, then the class goes', async (t) => {
+  const h = await setup();
+  t.mock.timers.enable({ apis: ['setTimeout'] });
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9, urgency: 100.2 } }));
+  const prs = h.row('alpha').cells[PRS]!;
+  assert.ok(lit(h).length === 1 && lit(h)[0] === prs, 'only the PRs cell: urgency still shows 100');
+  t.mock.timers.tick(2499);
+  assert.ok(prs.classList.contains('changed'));
+  t.mock.timers.tick(1);
+  assert.equal(lit(h).length, 0);
+});
+
+test('no flash on first render, sort click, window change, or a snapshot with identical data', async (t) => {
+  const h = await setup();
+  t.mock.timers.enable({ apis: ['setTimeout'] });
+  assert.equal(lit(h).length, 0, 'first render');
+  header(h, 'Repo').click();
+  assert.equal(lit(h).length, 0, 'sort click');
+  const picker = h.doc.querySelector<HTMLSelectElement>('.window-picker')!;
+  picker.value = '7d';
+  picker.dispatchEvent(new h.win.Event('change') as unknown as Event);
+  assert.equal(lit(h).length, 0, 'window change');
+  h.emit('craft:snapshot', snapshot());
+  assert.equal(lit(h).length, 0, 'identical snapshot');
+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));
+  h.doc.querySelector<HTMLElement>('[data-view="repos"]')!.click();
+  assert.equal(lit(h).length, 0, 'a snapshot while the table is hidden');
+  dragRow(h, 'beta', 2.5 * ROW_HEIGHT, { drop: false });
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 10 } }));
+  pointer(h, h.win as unknown as EventTarget, 'pointerup', 2.5 * ROW_HEIGHT);
+  assert.equal(h.row('alpha').cells[PRS]!.textContent, '10');
+  assert.equal(lit(h).length, 0, 'a snapshot mid-drag, then the drop');
+});
+
+test('the first data for a repo does not flash', async (t) => {
+  const h = await setup({ snap: snapshot([repo('alpha', null, { fetched_at: null }), repo('beta', {})]) });
+  t.mock.timers.enable({ apis: ['setTimeout'] });
+  h.emit('craft:snapshot', snapshot([repo('alpha', { open_prs: 4 }), repo('beta', {}), repo('delta', { open_prs: 5 })]));
+  assert.equal(h.row('alpha').cells[PRS]!.textContent, '4');
+  assert.equal(lit(h).length, 0, 'a repo fetched for the first time, and a repo new to the config');
+});
+
+test('a second change restarts the flash', async (t) => {
+  const h = await setup();
+  t.mock.timers.enable({ apis: ['setTimeout'] });
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));
+  const prs = h.row('alpha').cells[PRS]!;
+  t.mock.timers.tick(2000);
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 10 } }));
+  t.mock.timers.tick(2000);
+  assert.ok(prs.classList.contains('changed'), 'the first flash’s timer was cleared');
+  t.mock.timers.tick(500);
+  assert.ok(!prs.classList.contains('changed'));
+});
+
+test('a highlight continues from its timer when its row moves or the table is shown again', async (t) => {
+  const h = await setup();
+  t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() });
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 }, beta: { open_prs: 9 } }));
+  const alphaPrs = h.row('alpha').cells[PRS]!;
+  const betaPrs = h.row('beta').cells[PRS]!;
+  t.mock.timers.tick(1000);
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9, urgency: 400 }, beta: { open_prs: 9 } }));
+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'alpha moved; beta and gamma did not');
+  assert.equal(alphaPrs.style.animationDelay, '-1000ms');
+  assert.equal(betaPrs.style.animationDelay, '', 'a row that did not move keeps its running animation');
+  assert.equal(h.row('alpha').cells[URGENCY]!.style.animationDelay, '', 'the urgency cell started its highlight now');
+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();
+  t.mock.timers.tick(500);
+  h.doc.querySelector<HTMLElement>('[data-view="repos"]')!.click();
+  assert.equal(betaPrs.style.animationDelay, '-1500ms');
+  assert.equal(h.row('alpha').cells[URGENCY]!.style.animationDelay, '-500ms');
+  t.mock.timers.tick(1000);
+  assert.ok(!alphaPrs.classList.contains('changed'));
+  assert.equal(alphaPrs.style.animationDelay, '', 'the timer clears the delay with the class');
+});
+
+test('a snapshot does not rebuild the urgent repo picker', async () => {
+  const h = await setup();
+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();
+  const options = () => [...h.doc.querySelectorAll('#urgent-repo option')];
+  const before = options();
+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));
+  assert.ok(options().length === 4 && options().every((o, i) => o === before[i]), 'the same option elements');
+  h.emit('craft:snapshot', snapshot([repo('alpha', {}), repo('delta', {})]));
+  assert.deepEqual(options().map((o) => o.textContent), ['All repositories', 'alpha', 'delta']);
+});
+
 // ───────────────────────────── status ─────────────────────────────
 
 test('status pill, failures banner and budget', async () => {
diff --git a/ui/src/app.ts b/ui/src/app.ts
--- a/ui/src/app.ts
+++ b/ui/src/app.ts
@@ -3,9 +3,9 @@
 
 import {
   COLUMNS, RECENT, WINDOWS, MANUAL, windowById, value, orderedEntries, nextSort, dropIndex,
-  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing,
+  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing, formatNum, displayValue, changedCells,
 } from './model.ts';
-import type { Column, Meta, RepoEntry, Snapshot, Sort } from './model.ts';
+import type { Column, Meta, RepoEntry, RepoStats, Snapshot, Sort, Value } from './model.ts';
 
 /** What the UI needs from the host: Tauri commands and events, window chrome. */
 export interface Api {
@@ -16,6 +16,9 @@ export interface Api {
 
 type View = 'repos' | 'urgent';
 
+/** What triggered a render: a snapshot from the host, the clock, or the user. */
+type Reason = 'data' | 'clock' | 'user';
+
 interface State {
   snap: Snapshot | null;
   meta: Meta;
@@ -61,11 +64,51 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Props = {}, ..
 const localTime = (iso: string | null | undefined): string =>
   (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '');
 const clock = (t: number): string => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
-const num = (n: number | string | null | undefined): string => Number(n ?? 0).toLocaleString('en-US');
+const num = formatNum;
 const gh = (repo: string, path = ''): string => `https://github.com/${repo}${path}`;
 const shortName = (repo: string): string => repo.split('/').pop() ?? repo;
 const classes = (...cs: (string | false | null | undefined)[]): string => cs.filter(Boolean).join(' ');
 const DAY = 86400e3;
+const shortSha = (sha: string | null) => sha?.slice(0, 7);
+const isElement = (n: Node): n is Element => n.nodeType === n.ELEMENT_NODE;
+
+function setTitle(n: Element, title: string | null): void {
+  if (title === null) n.removeAttribute('title'); else n.setAttribute('title', title);
+}
+
+/** Puts `node` in `td` unless `td` already holds an equal node. When the two
+ * differ only in the outer element's `title`, the node in `td` stays and
+ * takes the new title. */
+function setCell(td: HTMLTableCellElement, node: Node): void {
+  const old = td.childNodes.length === 1 ? td.firstChild! : null;
+  if (old && isElement(old) && isElement(node)) {
+    const title = node.getAttribute('title');
+    setTitle(node, old.getAttribute('title'));
+    if (old.isEqualNode(node)) { setTitle(old, title); return; }
+    setTitle(node, title);
+  } else if (old?.isEqualNode(node)) return;
+  td.replaceChildren(node);
+}
+
+/** The Repo cell's tooltip: the last attempt's error, no access, or when the data was fetched. */
+function repoTip(entry: RepoEntry): string {
+  if (entry.error) return `Last attempt failed: ${entry.error}`;
+  if (entry.inaccessible) return 'Not accessible (private, or not found)';
+  return entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
+}
+
+/** A date column's cell; `oldest_open_pr_at` is marked `old` past 14 days. */
+function dateCell(key: string, v: Value, text: string, now: number): HTMLElement {
+  if (!v) return el('span', { class: 'muted' }, text);
+  const old = key === 'oldest_open_pr_at' && now - Date.parse(String(v)) > 14 * DAY;
+  return el('span', { title: localTime(String(v)), class: old ? 'old' : '' }, text);
+}
+
+/** A footer cell's text: the repo count under the first column, totals under count columns. */
+function footText(c: Column, i: number, repos: number, t: Record<string, number>): string {
+  if (i === 0) return `${repos} repos`;
+  return c.kind === 'num' ? num(Math.round(t[c.key] ?? 0)) : '';
+}
 
 /** The toolbar's refresh arrow, as an element (SVG needs its namespace). */
 function refreshIcon(): SVGSVGElement {
@@ -98,7 +141,7 @@ export function start(api: Api): () => void {
   }
 
   const link = (text: string, url: string, cls = ''): HTMLElement =>
-    el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text);
+    el('span', { class: `link ${cls}`, dataset: { url }, onclick: (e: Event) => openUrl(url, e) }, text);
 
   /** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */
   function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement {
@@ -107,86 +150,105 @@ export function start(api: Api): () => void {
     return el('span', { class: 'app-icon none', 'aria-hidden': 'true' }, shortName(entry.repo).charAt(0).toUpperCase());
   }
 
-  /** Cell content for a column. */
-  function cell(entry: RepoEntry, col: Column): Node {
+  /** Cell content for a column; its text is `displayValue`'s, which the highlight compares. */
+  function cell(entry: RepoEntry, col: Column, now: number): Node {
     const v = value(entry, col.key, state.window);
+    const text = displayValue(entry, col.key, state.window, now);
     const s = entry.stats;
     if (col.key === 'name') {
-      const tip = entry.error
-        ? `Last attempt failed: ${entry.error}`
-        : entry.inaccessible ? 'Not accessible (private, or not found)'
-        : entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
-      return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)));
-    }
-    if (col.key === 'fetched') {
-      const age = v ? el('span', { title: `Fetched ${localTime(String(v))}` }, ago(String(v))) : el('span', { class: 'muted', title: 'Not fetched yet' }, '—');
-      return el('span', { class: 'fetched' }, age, rowRefresh(entry));
+      return el('span', { class: 'repo', title: repoTip(entry) }, el('span', { class: 'dot' }), appIcon(entry), link(text, gh(entry.repo)));
     }
-    if (!s) return entry.inaccessible ? document.createTextNode('') : el('span', { class: 'muted' }, '—');
+    if (!s) return entry.inaccessible ? document.createTextNode(text) : el('span', { class: 'muted' }, text);
     switch (col.key) {
       case 'urgency': {
         const a = Math.min(1, Number(v) / 350) * 0.45;
-        return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, String(Math.round(Number(v))));
+        return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, text);
       }
-      case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, num(v));
-      case 'open_prs': return link(num(v), gh(entry.repo, '/pulls'));
-      case 'open_issues': return link(num(v), gh(entry.repo, '/issues'));
+      case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, text);
+      case 'open_prs': return link(text, gh(entry.repo, '/pulls'));
+      case 'open_issues': return link(text, gh(entry.repo, '/issues'));
       case 'recent_commits': case 'recent_prs': case 'recent_merged': case 'recent_issues': case 'recent_people':
-        if (v === null) {
-          const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';
-          return el('span', { class: 'muted', title: why }, '—');
-        }
-        return el('span', {
-          class: v ? 'active' : 'zero',
-          title: state.window === 'release' ? `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at)} ago)` : null,
-        }, num(v));
-      case 'commits_since_build': {
-        const lag = s.since_build;
-        if (!lag) return el('span', { class: 'muted' }, '—');
-        const short = (sha: string | null) => sha?.slice(0, 7);
-        const tip = {
-          exact: `${num(v)} commits on main since ${s.latest_build} (${short(lag.from_sha)})`,
-          branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,
-          by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it`
-            + (lag.from_sha ? `, counted from ${short(lag.from_sha)}` : ''),
-        }[lag.basis];
-        const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;
-        const text = `${lag.basis === 'exact' ? '' : '~'}${num(v)}`;
-        return el('span', { title: tip }, from ? link(text, gh(entry.repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero') : text);
-      }
-      case 'latest_build':
-        if (!v) return el('span', { class: 'muted' }, '—');
-        return el('span', {}, link(String(v), gh(entry.repo, `/releases/tag/${encodeURIComponent(v)}`)),
-          s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);
+        return recentCell(s, v, text, now);
+      case 'commits_since_build': return sinceBuildCell(entry.repo, s, v, text);
+      case 'latest_build': return latestBuildCell(entry.repo, s, v, text);
     }
-    if (col.kind === 'date') {
-      if (!v) return el('span', { class: 'muted' }, '—');
-      const old = col.key === 'oldest_open_pr_at' && Date.now() - Date.parse(String(v)) > 14 * DAY;
-      return el('span', { title: localTime(String(v)), class: old ? 'old' : '' }, ago(String(v)));
+    if (col.kind === 'date') return dateCell(col.key, v, text, now);
+    return document.createTextNode(text);
+  }
+
+  /** A recent-activity cell; in the release window its tooltip names the release the count starts at. */
+  function recentCell(s: RepoStats, v: Value, text: string, now: number): HTMLElement {
+    if (v === null) {
+      const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';
+      return el('span', { class: 'muted', title: why }, text);
     }
-    return document.createTextNode(num(v));
+    let tip: string | null = null;
+    if (state.window === 'release') tip = `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at, now)} ago)`;
+    return el('span', { class: v ? 'active' : 'zero', title: tip }, text);
+  }
+
+  /** The commits-since-release cell: the count links to the compare view, and the tooltip says how it was counted. */
+  function sinceBuildCell(repo: string, s: RepoStats, v: Value, text: string): HTMLElement {
+    const lag = s.since_build;
+    if (!lag) return el('span', { class: 'muted' }, text);
+    const counted = lag.from_sha ? `, counted from ${shortSha(lag.from_sha)}` : '';
+    const tip = {
+      exact: `${num(v)} commits on main since ${s.latest_build} (${shortSha(lag.from_sha)})`,
+      branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,
+      by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it${counted}`,
+    }[lag.basis];
+    const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;
+    if (!from) return el('span', { title: tip }, text);
+    return el('span', { title: tip }, link(text, gh(repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero'));
+  }
+
+  /** The latest release cell: the tag is a link, and a pre-release adds a PRE badge after it, matching `text`. */
+  function latestBuildCell(repo: string, s: RepoStats, v: Value, text: string): HTMLElement {
+    if (!v) return el('span', { class: 'muted' }, text);
+    return el('span', {}, link(String(v), gh(repo, `/releases/tag/${encodeURIComponent(v)}`)),
+      s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);
   }
 
-  /** The tiny button that refetches just this row. */
-  function rowRefresh(entry: RepoEntry): HTMLButtonElement {
+  /** The Fetched cell: the data's age and the row's refresh button. The cell
+   * is built once per row. Later renders change only the age text, its
+   * tooltip and `muted` class, and the button's spin and tooltip. */
+  function updateFetched(td: HTMLTableCellElement, entry: RepoEntry, now: number): void {
+    let box = td.firstElementChild;
+    if (!box?.classList.contains('fetched')) {
+      box = el('span', { class: 'fetched' }, el('span'), rowRefresh(entry.repo));
+      td.replaceChildren(box);
+    }
+    const age = box.firstElementChild as HTMLElement;
+    const text = displayValue(entry, 'fetched', state.window, now);
+    if (age.textContent !== text) age.textContent = text;
+    age.title = entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
+    age.classList.toggle('muted', !entry.fetched_at);
+    const button = box.lastElementChild as HTMLButtonElement;
     const busy = state.snap ? isRefreshing(state.snap, entry.repo) : false;
+    button.classList.toggle('spinning', busy);
+    button.title = busy ? 'Refreshing this repo…' : `Refresh only ${shortName(entry.repo)}`;
+  }
+
+  /** The tiny button that refetches just this row; `updateFetched` sets its spin and tooltip. */
+  function rowRefresh(repo: string): HTMLButtonElement {
     return el('button', {
-      class: classes('row-refresh', busy && 'spinning'),
-      title: busy ? 'Refreshing this repo…' : `Refresh only ${shortName(entry.repo)}`,
-      'aria-label': `Refresh ${shortName(entry.repo)}`,
+      class: 'row-refresh',
+      'aria-label': `Refresh ${shortName(repo)}`,
       onclick: (e: Event) => {
         e.stopPropagation();
         (e.currentTarget as HTMLElement).classList.add('spinning');
-        api.invoke('refresh_repo', { repo: entry.repo }).catch((err: unknown) => console.warn(err));
+        api.invoke('refresh_repo', { repo }).catch((err: unknown) => console.warn(err));
       },
     }, refreshIcon());
   }
 
   // ───────────────────────────── rendering ─────────────────────────────
 
-  /** The group row, built once: rebuilding it would destroy the window
-   * dropdown, closing it mid-choice whenever a poll or the clock re-renders. */
+  /** The header rows, built once and updated in place: rebuilding them would
+   * destroy the window dropdown, closing it mid-choice whenever a poll or the
+   * clock re-renders. */
   let groupRow: HTMLTableRowElement | null = null;
+  let colsRow: HTMLTableRowElement | null = null;
 
   function renderHead(): void {
     if (!groupRow) {
@@ -197,26 +259,25 @@ export function start(api: Api): () => void {
         prev = c.group;
         row.append(el('th', { class: c.group ? 'group-start' : '' }, c.group === RECENT ? windowPicker() : c.group));
       }
-      $('thead').replaceChildren(row);
+      colsRow = el('tr', { class: 'cols' }, COLUMNS.map((c) => el('th', {
+        onclick: () => {
+          state.sort = nextSort(state.sort, c.key);
+          save('sort', state.sort);
+          render();
+        },
+      }, c.label)));
+      $('thead').replaceChildren(row, colsRow);
       groupRow = row;
     }
     const picker = groupRow.querySelector<HTMLSelectElement>('.window-picker')!;
     if (picker.value !== state.window) picker.value = state.window;
-    const cols = el('tr', { class: 'cols' });
+    const cols = colsRow!;
     COLUMNS.forEach((c, i) => {
       const sorted = state.sort.key === c.key;
-      cols.append(el('th', {
-        title: c.title?.replace('{span}', windowById(state.window).span) ?? `Sort by ${c.label}`,
-        class: classes(sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', groupStart(i)),
-        onclick: () => {
-          state.sort = nextSort(state.sort, c.key);
-          save('sort', state.sort);
-          render();
-        },
-      }, c.label));
+      const th = cols.cells[i]!;
+      th.title = c.title?.replace('{span}', windowById(state.window).span) ?? `Sort by ${c.label}`;
+      th.className = classes(sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', groupStart(i));
     });
-    groupRow.nextSibling?.remove();
-    $('thead').append(cols);
   }
 
   /** The dropdown that heads the recent-activity columns. */
@@ -233,30 +294,129 @@ export function start(api: Api): () => void {
     return i > 0 && COLUMNS[i - 1]!.group !== COLUMNS[i]!.group ? 'group-start' : '';
   }
 
-  function renderTable(snap: Snapshot): void {
+  // Rows are kept per repo, and their cells are updated in place. While the
+  // pointer is over the rows, a snapshot or the clock leaves their order
+  // alone, and the order is applied when the pointer leaves. Sorting, window
+  // changes and drops apply it at once.
+
+  const rows = new Map<string, HTMLTableRowElement>();
+  let emptyRow: HTMLTableRowElement | null = null;
+  let footRow: HTMLTableRowElement | null = null;
+  let hovering = false;
+  let pendingOrder = false;
+
+  function newRow(repo: string): HTMLTableRowElement {
+    const tr: HTMLTableRowElement = el('tr', {
+      dataset: { repo },
+      title: 'Click for this repo’s most urgent issues · drag to reorder',
+      onpointerdown: startRowDrag,
+      onclick: () => {
+        if (justDragged) return;
+        state.urgentRepo = tr.dataset.repo ?? '';
+        setView('urgent');
+      },
+    }, COLUMNS.map((_, i) => el('td', { class: groupStart(i) })));
+    return tr;
+  }
+
+  function updateRow(tr: HTMLTableRowElement, entry: RepoEntry, snap: Snapshot, now: number): void {
+    const stale = !entry.fetched_at || now - Date.parse(entry.fetched_at) > snap.stale_after_secs * 1000;
+    tr.className = classes(stale && 'stale', entry.error && 'error', snap.fetching === entry.repo && 'fetching');
+    COLUMNS.forEach((c, i) => {
+      if (c.key === 'fetched') updateFetched(tr.cells[i]!, entry, now);
+      else setCell(tr.cells[i]!, cell(entry, c, now));
+    });
+  }
+
+  /** How long a changed cell stays highlighted. It matches `td.changed`'s animation in style.css. */
+  const FLASH_MS = 2500;
+  const flashTimers = new WeakMap<HTMLTableCellElement, ReturnType<typeof setTimeout>>();
+  const flashStarts = new WeakMap<HTMLTableCellElement, number>();
+
+  /** Highlights a cell for FLASH_MS, restarting the highlight if it is already on. */
+  function flash(td: HTMLTableCellElement): void {
+    clearTimeout(flashTimers.get(td));
+    td.classList.remove('changed');
+    td.style.animationDelay = '';
+    td.getBoundingClientRect(); // Forcing a reflow makes the animation start again.
+    td.classList.add('changed');
+    flashStarts.set(td, Date.now());
+    flashTimers.set(td, setTimeout(() => {
+      td.classList.remove('changed');
+      td.style.animationDelay = '';
+      flashTimers.delete(td);
+    }, FLASH_MS));
+  }
+
+  /** The browser starts a cell's animation over when its row moves or the
+   * table is shown again. A negative delay continues each highlight in
+   * `cells` from where its timer is. Call it only for cells whose animation
+   * restarted: on a running animation the delay moves it forward. */
+  function resumeFlashes(cells: Iterable<HTMLTableCellElement>): void {
+    const now = Date.now();
+    for (const td of cells) {
+      const start = flashStarts.get(td);
+      if (start !== undefined && td.classList.contains('changed')) td.style.animationDelay = `${start - now}ms`;
+    }
+  }
+
+  /** Highlights the cells whose shown text differs between `prev`'s entries and the snapshot just drawn. */
+  function flashChanges(prev: ReadonlyMap<string, RepoEntry>, snap: Snapshot): void {
+    // A hidden table shows nothing, and mid-drag render() leaves the cells as they were.
+    if (state.view !== 'repos' || drag?.moved) return;
+    const now = Date.now();
+    for (const entry of snap.repos) {
+      const tr = rows.get(entry.repo);
+      if (!tr) continue;
+      for (const key of changedCells(prev.get(entry.repo), entry, state.window, now)) {
+        flash(tr.cells[COLUMNS.findIndex((c) => c.key === key)]!);
+      }
+    }
+  }
+
+  /** Puts `want` in order at the top of `tbody`, moving only rows that are out of place, and removes the rest. */
+  function placeRows(tbody: HTMLElement, want: readonly HTMLTableRowElement[]): void {
+    let at = tbody.firstElementChild;
+    for (const tr of want) {
+      if (tr === at) { at = at.nextElementSibling; continue; }
+      if (at) at.before(tr);
+      else tbody.append(tr);
+      resumeFlashes(tr.cells);
+    }
+    while (at) { const next = at.nextElementSibling; at.remove(); at = next; }
+  }
+
+  function renderTable(snap: Snapshot, reason: Reason): void {
     renderHead();
-    const rows = orderedEntries(snap.repos, state.sort, state.order, state.window);
+    const tbody = $('tbody');
+    const entries = orderedEntries(snap.repos, state.sort, state.order, state.window);
     const now = Date.now();
-    $('tbody').replaceChildren(...rows.map((e) => {
-      const stale = !e.fetched_at || now - Date.parse(e.fetched_at) > snap.stale_after_secs * 1000;
-      return el('tr', {
-        class: classes(stale && 'stale', e.error && 'error'),
-        dataset: { repo: e.repo },
-        title: 'Click for this repo’s most urgent issues · drag to reorder',
-        onpointerdown: startRowDrag,
-        onclick: () => {
-          if (justDragged) return;
-          state.urgentRepo = e.repo;
-          setView('urgent');
-        },
-      }, COLUMNS.map((c, i) => el('td', { class: groupStart(i) }, cell(e, c))));
-    }));
-    if (!rows.length) {
-      $('tbody').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.')));
+    const configured = new Set(entries.map((e) => e.repo));
+    for (const [repo, tr] of rows) if (!configured.has(repo)) { tr.remove(); rows.delete(repo); }
+    const want = entries.map((e) => {
+      let tr = rows.get(e.repo);
+      if (!tr) { tr = newRow(e.repo); rows.set(e.repo, tr); }
+      updateRow(tr, e, snap, now);
+      return tr;
+    });
+    if (!want.length) {
+      emptyRow ??= el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.'));
+      tbody.replaceChildren(emptyRow);
+      pendingOrder = false;
+    } else if (hovering && reason !== 'user') {
+      emptyRow?.remove();
+      for (const tr of want) if (tr.parentNode !== tbody) tbody.append(tr);
+      pendingOrder = want.some((tr, i) => tbody.children[i] !== tr);
+    } else {
+      placeRows(tbody, want);
+      pendingOrder = false;
     }
     const t = totals(snap.repos, state.window);
-    $('tfoot').replaceChildren(el('tr', {}, COLUMNS.map((c, i) =>
-      el('td', { class: groupStart(i) }, i === 0 ? `${snap.repos.length} repos` : c.kind === 'num' ? num(Math.round(t[c.key] ?? 0)) : ''))));
+    const foot = footRow ??= el('tr', {}, COLUMNS.map((_, i) => el('td', { class: groupStart(i) })));
+    if (foot.parentNode !== $('tfoot')) $('tfoot').replaceChildren(foot);
+    COLUMNS.forEach((c, i) => {
+      foot.cells[i]!.textContent = footText(c, i, snap.repos.length, t);
+    });
   }
 
   // ───────────────────────────── row dragging ─────────────────────────────
@@ -294,7 +454,7 @@ export function start(api: Api): () => void {
     const others = [...tbody.children].filter((r) => r !== drag!.row);
     const mids = others.map((r) => { const b = r.getBoundingClientRect(); return b.top + b.height / 2; });
     const next = others[dropIndex(mids, e.clientY)] ?? null;
-    if (drag.row.nextSibling !== next) tbody.insertBefore(drag.row, next);
+    if (drag.row.nextSibling !== next) { tbody.insertBefore(drag.row, next); resumeFlashes(drag.row.cells); }
   }
 
   /** Ends a press; true if it had become a drag. */
@@ -324,11 +484,18 @@ export function start(api: Api): () => void {
     if (finishRowDrag()) render();
   }
 
+  /** The repos the urgent view's picker lists. The picker is rebuilt only when they change. */
+  let pickerRepos: string | null = null;
+
   function renderUrgent(snap: Snapshot): void {
     const sel = $<HTMLSelectElement>('urgent-repo');
-    sel.replaceChildren(el('option', { value: '' }, 'All repositories'),
-      ...snap.repos.map((r) => el('option', { value: r.repo }, shortName(r.repo))));
-    sel.value = state.urgentRepo;
+    const repos = snap.repos.map((r) => r.repo).join('\n');
+    if (repos !== pickerRepos) {
+      sel.replaceChildren(el('option', { value: '' }, 'All repositories'),
+        ...snap.repos.map((r) => el('option', { value: r.repo }, shortName(r.repo))));
+      pickerRepos = repos;
+    }
+    if (sel.value !== state.urgentRepo) sel.value = state.urgentRepo;
     $<HTMLInputElement>('critical-only').checked = state.criticalOnly;
     const items = urgentAcross(snap.repos, { repo: state.urgentRepo, criticalOnly: state.criticalOnly });
     const SEVERE = /crash|panic|freeze|hang|data loss|corruption|won't|blank|security/;
@@ -390,20 +557,25 @@ export function start(api: Api): () => void {
     $('repo-count').textContent = String(snap.repos.length);
   }
 
-  function render(): void {
-    // Rebuilding the table mid-drag would drop the row out of the user's hand;
+  function render(reason: Reason = 'user'): void {
+    // Reordering the table mid-drag would move rows under the user's hand;
     // the drop renders anyway.
     const snap = state.snap;
     if (!snap || drag?.moved) return;
     renderStatus(snap);
     document.querySelectorAll<HTMLElement>('.view').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.view === state.view)));
-    $('repos-view').hidden = state.view !== 'repos';
+    const table = $('repos-view');
+    const shown = table.hidden && state.view === 'repos';
+    table.hidden = state.view !== 'repos';
+    if (shown) resumeFlashes(table.querySelectorAll<HTMLTableCellElement>('td.changed'));
     $('urgent-view').hidden = state.view !== 'urgent';
-    if (state.view === 'repos') renderTable(snap); else renderUrgent(snap);
+    if (state.view === 'repos') renderTable(snap, reason); else renderUrgent(snap);
   }
 
   function setView(v: View): void {
     state.view = v;
+    // A hidden table gets no pointerleave.
+    if (v !== 'repos') hovering = false;
     save('view', v);
     render();
   }
@@ -442,16 +614,37 @@ export function start(api: Api): () => void {
     else if (mod && e.key === '2') setView('urgent');
   });
 
-  api.listen<Snapshot>('craft:snapshot', (s) => { state.snap = s; render(); });
+  // pointerenter and pointerleave fire once for the whole tbody.
+  $('tbody').addEventListener('pointerenter', () => { hovering = true; });
+  /** Ends a hover and applies the order a snapshot held back. */
+  function endHover(): void {
+    hovering = false;
+    if (pendingOrder) render();
+  }
+  $('tbody').addEventListener('pointerleave', endHover);
+  // With overlay scrollbars, WebKit tracks the pointer only in the focused
+  // window, so a pointer that leaves an unfocused window sends no pointerleave.
+  window.addEventListener('blur', endHover);
+  api.listen<boolean>('craft:focus', (focused) => { if (!focused) endHover(); });
+  document.addEventListener('visibilitychange', () => { if (document.hidden) endHover(); });
+
+  // Only a snapshot event highlights cells. The first render (from
+  // get_snapshot), the clock, sorting, a window change and a drop do not.
+  api.listen<Snapshot>('craft:snapshot', (s) => {
+    const prev = new Map((state.snap?.repos ?? []).map((e) => [e.repo, e] as const));
+    state.snap = s;
+    render('data');
+    flashChanges(prev, s);
+  });
   api.listen<Meta>('craft:meta', applyMeta);
   // Relative times and staleness change with the clock alone.
-  const clockTimer = setInterval(render, 15_000);
+  const clockTimer = setInterval(() => render('clock'), 15_000);
 
   (async () => {
     try {
       applyMeta(await api.invoke<Meta>('get_meta'));
       state.snap = await api.invoke<Snapshot>('get_snapshot');
-      render();
+      render('data');
     } catch (e) {
       console.error(e);
     } finally {
diff --git a/ui/src/model.test.ts b/ui/src/model.test.ts
--- a/ui/src/model.test.ts
+++ b/ui/src/model.test.ts
@@ -2,7 +2,7 @@ import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import {
   sortEntries, manualOrder, orderedEntries, nextSort, dropIndex, MANUAL, versionKey, freshness, isStale, ago, compact, tokenStatus,
-  totals, urgentAcross, isRefreshing, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN,
+  totals, urgentAcross, isRefreshing, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN, displayValue, changedCells,
 } from './model.ts';
 import type { Activity, RepoEntry, RepoStats, UrgentIssue } from './model.ts';
 
@@ -199,6 +199,62 @@ test('recent-activity columns follow the selected window', () => {
   assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']);
 });
 
+test('displayValue is the text each column shows', () => {
+  const now = Date.parse('2026-10-08T12:00:00Z');
+  const act: Activity = { commits: 1234, prs_opened: 0, prs_merged: 2, issues_opened: 0, people: 3 };
+  const e = entry('app', {
+    urgency: 100.4, critical_issues: 0, open_prs: 1234, open_issues: 2, last_commit_at: '2026-10-08T09:00:00Z', last_issue_at: null,
+    recent: { '4h': act }, latest_build: 'v1.2.0', latest_build_prerelease: true, latest_build_downloads: 12345,
+    since_build: { commits: 1500, basis: 'branched', from_sha: null },
+  }, '2026-10-08T10:00:00Z');
+  const shown = (key: string, x: RepoEntry = e, window = '4h') => displayValue(x, key, window, now);
+  assert.equal(shown('name'), 'app');
+  assert.equal(shown('fetched'), '2h');
+  assert.equal(shown('urgency'), '100');
+  assert.equal(shown('critical_issues'), '0');
+  assert.equal(shown('open_prs'), '1,234');
+  assert.equal(shown('last_commit_at'), '3h');
+  assert.equal(shown('last_issue_at'), '—', 'a missing date');
+  assert.equal(shown('recent_commits'), '1,234');
+  assert.equal(shown('recent_commits', e, '7d'), '—', 'a window not fetched yet');
+  assert.equal(shown('commits_since_build'), '~1,500', 'an estimated lag');
+  assert.equal(shown('latest_build'), 'v1.2.0 PRE');
+  assert.equal(shown('latest_build_downloads'), '12,345');
+  const plain = entry('app', { latest_build: 'v1.2.0', latest_build_prerelease: false, since_build: { commits: 3, basis: 'exact', from_sha: 'x' } });
+  assert.equal(shown('latest_build', plain), 'v1.2.0');
+  assert.equal(shown('commits_since_build', plain), '3');
+  assert.equal(shown('urgency', plain), '0', 'a stat the cache lacks');
+  const bare = entry('app', { latest_build: null, since_build: null });
+  assert.equal(shown('latest_build', bare), '—');
+  assert.equal(shown('commits_since_build', bare), '—');
+  assert.equal(shown('urgency', entry('app', null, null)), '—', 'no stats yet');
+  assert.equal(shown('fetched', entry('app', null, null)), '—', 'never fetched');
+  const hidden = { ...entry('app', null), inaccessible: true };
+  assert.equal(shown('urgency', hidden), '', 'an inaccessible repo');
+  assert.equal(shown('fetched', hidden), 'now', 'an inaccessible repo still shows when it was checked');
+  assert.equal(shown('name', entry('app', null, null)), 'app');
+});
+
+test('changedCells lists the columns whose shown text changed', () => {
+  const now = Date.parse('2026-10-08T12:00:00Z');
+  const act = (commits: number): Activity => ({ commits, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 });
+  const base: Partial<RepoStats> = { urgency: 100.4, open_prs: 1, last_commit_at: '2026-10-08T09:00:00Z', recent: { '4h': act(1), '1d': act(1) } };
+  const prev = entry('app', base);
+  const next = (s: Partial<RepoStats>) => entry('app', { ...base, ...s });
+  assert.deepEqual(changedCells(prev, prev, '4h', now + 60 * 60_000), [], 'an hour later, with the same data');
+  assert.deepEqual(changedCells(prev, next({ open_prs: 2 }), '4h', now), ['open_prs']);
+  assert.deepEqual(changedCells(prev, next({ last_commit_at: '2026-10-08T11:59:00Z' }), '4h', now), ['last_commit_at']);
+  assert.deepEqual(changedCells(prev, next({ urgency: 100.2 }), '4h', now), [], 'urgency rounds to the same 100');
+  assert.deepEqual(changedCells(prev, entry('app', base, '2026-10-08T11:00:00Z'), '4h', now), [], 'a new fetch time alone');
+  assert.deepEqual(changedCells(undefined, next({ open_prs: 2 }), '4h', now), [], 'a repo new to the table');
+  assert.deepEqual(changedCells(entry('app', null, null), next({ open_prs: 2 }), '4h', now), [], 'the first data for a repo');
+  const other = next({ recent: { '4h': act(1), '1d': act(5) } });
+  assert.deepEqual(changedCells(prev, other, '4h', now), [], 'a change in another window');
+  assert.deepEqual(changedCells(prev, other, '1d', now), ['recent_commits'], 'the selected window');
+  const filled = next({ open_prs: 2, recent: { '4h': act(1), '1d': act(1), '7d': act(9) } });
+  assert.deepEqual(changedCells(prev, filled, '7d', now), ['open_prs'], 'a window read for the first time');
+});
+
 test('a row is refreshing while its repo is being fetched or queued', () => {
   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/a'), true);
   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/b'), true);
diff --git a/ui/src/model.ts b/ui/src/model.ts
--- a/ui/src/model.ts
+++ b/ui/src/model.ts
@@ -338,6 +338,58 @@ export function compact(n: number | null | undefined): string {
   return `${(n / 1e6).toFixed(1)}M`;
 }
 
+/** A count with thousands separators: 1,234. */
+export const formatNum = (n: Value | undefined): string => Number(n ?? 0).toLocaleString('en-US');
+
+/**
+ * The text a table cell shows for a column, with times aged to `now`: counts
+ * with separators, urgency rounded, `~` before an estimated lag, `PRE` after a
+ * pre-release build, and '—' where there is no data.
+ */
+export function displayValue(entry: RepoEntry, key: string, window: string, now: number): string {
+  const v = value(entry, key, window);
+  const s = entry.stats;
+  const col = COLUMN[key];
+  if (key === 'name') return String(v);
+  if (col?.kind === 'age') return v ? ago(String(v), now) : '—';
+  if (!s) return entry.inaccessible ? '' : '—';
+  switch (key) {
+    case 'urgency': return String(Math.round(Number(v)));
+    case 'commits_since_build': return lagText(s, v);
+    case 'latest_build': return buildText(s, v);
+  }
+  if (col?.recent) return v === null ? '—' : formatNum(v);
+  if (col?.kind === 'date') return v ? ago(String(v), now) : '—';
+  return formatNum(v);
+}
+
+/** The commits-since-release count, with `~` when it is an estimate. */
+function lagText(s: RepoStats, v: Value): string {
+  if (!s.since_build) return '—';
+  const estimate = s.since_build.basis === 'exact' ? '' : '~';
+  return `${estimate}${formatNum(v)}`;
+}
+
+/** The latest release's tag, with ` PRE` after a pre-release. */
+function buildText(s: RepoStats, v: Value): string {
+  if (!v) return '—';
+  return s.latest_build_prerelease ? `${v} PRE` : String(v);
+}
+
+/**
+ * The keys of the columns whose shown text differs between two versions of a
+ * repo's entry. Both are read at the same `now`, so a relative time that has
+ * only aged compares equal. It returns an empty list when either version has
+ * no stats. `age` columns (Fetched) are left out, and so are the
+ * recent-activity columns when `prev` has no activity for `window`.
+ */
+export function changedCells(prev: RepoEntry | undefined, next: RepoEntry, window: string, now: number): string[] {
+  if (!prev?.stats || !next.stats) return [];
+  const unread = !prev.stats.recent?.[window];
+  return COLUMNS.filter((c) => c.kind !== 'age' && !(c.recent && unread)
+    && displayValue(prev, c.key, window, now) !== displayValue(next, c.key, window, now)).map((c) => c.key);
+}
+
 /** Column totals for the footer row (counts only; dates and versions blank). */
 export function totals(entries: readonly RepoEntry[], window: string = DEFAULT_WINDOW): Record<string, number> {
   const out: Record<string, number> = {};
diff --git a/ui/style.css b/ui/style.css
--- a/ui/style.css
+++ b/ui/style.css
@@ -10,6 +10,7 @@
   --warn: #b7741a;
   --bad: #d23c3c;
   --hot-rgb: 210 60 60;
+  --flash-rgb: 47 111 224;
   --lib: #7a4fd0;
   --row-hover: rgba(47, 111, 224, .06);
   --opacity: .96;
@@ -26,6 +27,7 @@
     --warn: #f4bc70;
     --bad: #ff8f8f;
     --hot-rgb: 255 110 110;
+    --flash-rgb: 91 157 255;
     --lib: #b59bff;
     --row-hover: rgba(91, 157, 255, .07);
   }
@@ -104,6 +106,10 @@ body.reordering tbody tr:not(.dragging) td:first-child::before { content: none;
 .app-icon.pending { background: var(--surface); }
 tr.stale .repo .dot { background: var(--warn); }
 tr.error .repo .dot { background: var(--bad); }
+tr.fetching .repo .dot { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
+td.changed { animation: cell-flash 2.5s ease-out; }
+@keyframes cell-flash { from { box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / .3); } to { box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / 0); } }
+@media (prefers-reduced-motion: reduce) { tr.fetching .repo .dot { animation: none; } td.changed { animation: none; box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / .18); } }
 tr.stale td:not(:first-child) { opacity: .55; }
 .muted { color: var(--muted); }
 .fetched { display: inline-flex; align-items: center; gap: 3px; }
