diff --git a/apps/board/assets/latch/error-retrying-budget.png b/apps/board/assets/latch/error-retrying-budget.png
index a7c00b483..2470338d9 100644
Binary files a/apps/board/assets/latch/error-retrying-budget.png and b/apps/board/assets/latch/error-retrying-budget.png differ
diff --git a/apps/board/assets/latch/error-retrying-cooldown.png b/apps/board/assets/latch/error-retrying-cooldown.png
index 924056757..77d873657 100644
Binary files a/apps/board/assets/latch/error-retrying-cooldown.png and b/apps/board/assets/latch/error-retrying-cooldown.png differ
diff --git a/apps/board/assets/latch/error-retrying-next-sweep.png b/apps/board/assets/latch/error-retrying-next-sweep.png
index a28aa0e70..cec1fe7b7 100644
Binary files a/apps/board/assets/latch/error-retrying-next-sweep.png and b/apps/board/assets/latch/error-retrying-next-sweep.png differ
diff --git a/apps/board/assets/latch/layout.json b/apps/board/assets/latch/layout.json
index 95a63547f..1d6ee2c2d 100644
--- a/apps/board/assets/latch/layout.json
+++ b/apps/board/assets/latch/layout.json
@@ -136,7 +136,7 @@
"dotsY": 78
},
"superseded": {
- "dotsX": 634,
+ "dotsX": 547,
"dotsY": 78
}
}
diff --git a/apps/board/assets/latch/queued-budget.png b/apps/board/assets/latch/queued-budget.png
index 62b898282..f3e748640 100644
Binary files a/apps/board/assets/latch/queued-budget.png and b/apps/board/assets/latch/queued-budget.png differ
diff --git a/apps/board/assets/latch/queued-cooldown.png b/apps/board/assets/latch/queued-cooldown.png
index ef480ef0c..a08e5c853 100644
Binary files a/apps/board/assets/latch/queued-cooldown.png and b/apps/board/assets/latch/queued-cooldown.png differ
diff --git a/apps/board/assets/latch/queued-next-sweep.png b/apps/board/assets/latch/queued-next-sweep.png
index ccbe642e5..2b83b4a63 100644
Binary files a/apps/board/assets/latch/queued-next-sweep.png and b/apps/board/assets/latch/queued-next-sweep.png differ
diff --git a/apps/board/assets/latch/queued-review-in-flight.png b/apps/board/assets/latch/queued-review-in-flight.png
index 1693a6a14..b715d4042 100644
Binary files a/apps/board/assets/latch/queued-review-in-flight.png and b/apps/board/assets/latch/queued-review-in-flight.png differ
diff --git a/apps/board/assets/latch/superseded.png b/apps/board/assets/latch/superseded.png
index 655c0be0b..d030ee6b3 100644
Binary files a/apps/board/assets/latch/superseded.png and b/apps/board/assets/latch/superseded.png differ
diff --git a/apps/board/docs/superpowers/specs/2026-10-29-latch-animated-states-design.md b/apps/board/docs/superpowers/specs/2026-10-29-latch-animated-states-design.md
index 5db50c782..4f22d1ce0 100644
--- a/apps/board/docs/superpowers/specs/2026-10-29-latch-animated-states-design.md
+++ b/apps/board/docs/superpowers/specs/2026-10-29-latch-animated-states-design.md
@@ -55,22 +55,23 @@ title in Silkscreen 18 (`#ece8f4`) and a subtitle in IBM Plex Mono 13
| Armed (first time) | `armed` | open | Ready for another pass? | Resolve this thread to ask | `#f08fb0` | leg shuffle, eye blink |
| Queued | `queued` | resolved | Follow-up review queued | why it is waiting (below) | `#e6b860` | dots tick after the title |
| Running | `running` | resolved | Follow-up review running | Results post here when done | `#7fbfe8` | creature marches; ring loader on the right |
-| Error, retrying | `error` | resolved | Follow-up review hit a snag | Retrying on the next sweep | `#f2917e` | quick shake, dots tick |
+| Error, retrying | `error` | resolved | Follow-up review hit a snag | Trying again in a few minutes | `#f2917e` | quick shake, dots tick |
| Error, stuck | `error` | resolved | Follow-up review stuck | Reviewer notified | `#f2917e` | quick shake, blink |
| Done, comments | `armed` | open | Follow-up review done | N new comments. Resolve when ready | `#b9a0f2` | one hop, blink |
| Done, approved | `spent` | resolved | Follow-up review done | Approved. Nothing more to do | `#86cfa0` | hop with sparkles |
| Approved, no follow-up | `spent` | resolved | Approved | Nothing more to do | `#86cfa0` | hop with sparkles |
-| Superseded | `spent` | resolved | Superseded | A newer latch is live | `#8e88a0` | still |
+| Replaced | `spent` | resolved | Replaced | Use the newer thread | `#8e88a0` | still |
-Queued subtitles, by why the request is waiting: "Starts on the next
-sweep" (nothing blocks it), "Waiting out the cooldown", "Daily limit hit;
-starts tomorrow", "Waiting for the running review" (`review-in-flight`).
+Queued subtitles, by why the request is waiting: "Starts in a few
+minutes" (nothing blocks it), "Waiting between reviews" (the cooldown),
+"Daily limit reached. Starts tomorrow", "Waiting on another review"
+(`review-in-flight`).
These are the only reasons `decideRequest` can give a latch request that
clear by themselves; respond never blocks a latch.
Error, retrying names the wait the same way when a retry is blocked:
-"Retrying on the next sweep", "Retrying after the cooldown", "Daily limit
-hit; retrying tomorrow".
+"Trying again in a few minutes", "Trying again after a pause", "Daily
+limit reached. Trying again tomorrow".
Done-comments counts what actually landed: the reviewer's discussions on
the MR created after the follow-up launched (the launch stamp, below).
@@ -79,9 +80,13 @@ count the board cannot read, shows "Review posted. Resolve when ready".
(My call: the report JSON's findings are the drafted set, and the
reviewer can drop findings before posting.)
-The note body under the image keeps a plain sentence per state, so
-blocked images still say what is happening. The current armed and spent
-copy stays; new states get one sentence each.
+A sentence goes under the image only when the author has something to
+do: armed and done-comments. Every other state is the banner alone; its
+title and subtitle are the image's alt text, so a blocked image or a
+screen reader still says what is happening. A cached upload is reused only
+while its alt text matches the current copy, so a copy change re-draws the
+banner on each MR's next write. Banner and comment copy never names the
+board's own terms (latch, spent, sweep, cooldown).
## Replies and notifications
diff --git a/apps/board/scripts/preview-latch-banners.ts b/apps/board/scripts/preview-latch-banners.ts
index a9f6dd454..b82998f82 100644
--- a/apps/board/scripts/preview-latch-banners.ts
+++ b/apps/board/scripts/preview-latch-banners.ts
@@ -68,12 +68,16 @@ function write(name: string, bytes: Uint8Array): string {
return `gifs/${name}`;
}
-function card(src: string, sentence: string, caption?: string): string {
+function card(
+ src: string,
+ sentence: string | undefined,
+ caption?: string
+): string {
return `
${caption ? `${esc(caption)}` : ''}
Board reviewer@reviewer, just now
- ${markdown(sentence)}
+ ${sentence ? markdown(sentence) : ''}
`;
}
diff --git a/apps/board/src/__tests__/fixtures/latch-reference/latch-error-retrying.gif b/apps/board/src/__tests__/fixtures/latch-reference/latch-error-retrying.gif
index 242cc568c..5cc90aae7 100644
Binary files a/apps/board/src/__tests__/fixtures/latch-reference/latch-error-retrying.gif and b/apps/board/src/__tests__/fixtures/latch-reference/latch-error-retrying.gif differ
diff --git a/apps/board/src/__tests__/fixtures/latch-reference/latch-queued.gif b/apps/board/src/__tests__/fixtures/latch-reference/latch-queued.gif
index 0fda4d252..60e6620f2 100644
Binary files a/apps/board/src/__tests__/fixtures/latch-reference/latch-queued.gif and b/apps/board/src/__tests__/fixtures/latch-reference/latch-queued.gif differ
diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/error-retrying-next-sweep.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/error-retrying-next-sweep.png
index cf965d61a..fbabe1f17 100644
Binary files a/apps/board/src/__tests__/fixtures/latch-strips-2x/error-retrying-next-sweep.png and b/apps/board/src/__tests__/fixtures/latch-strips-2x/error-retrying-next-sweep.png differ
diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/queued-next-sweep.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/queued-next-sweep.png
index c557bb475..095e72e1c 100644
Binary files a/apps/board/src/__tests__/fixtures/latch-strips-2x/queued-next-sweep.png and b/apps/board/src/__tests__/fixtures/latch-strips-2x/queued-next-sweep.png differ
diff --git a/apps/board/src/__tests__/latch-land.test.ts b/apps/board/src/__tests__/latch-land.test.ts
index fbeb0f363..57c6d1010 100644
--- a/apps/board/src/__tests__/latch-land.test.ts
+++ b/apps/board/src/__tests__/latch-land.test.ts
@@ -168,7 +168,8 @@ describe('landFollowUp', () => {
phase: 'error',
stuck: true,
variant: 'error-stuck',
- replyDue: copyFor({ state: 'error-stuck' }).sentence,
+ replyDue:
+ "The follow-up review didn't finish after 3 tries. The reviewer has been told.",
})
);
expect(await h.land()).toBe('landed');
diff --git a/apps/board/src/__tests__/latch-markers.test.ts b/apps/board/src/__tests__/latch-markers.test.ts
index c28ac0634..72544bb3e 100644
--- a/apps/board/src/__tests__/latch-markers.test.ts
+++ b/apps/board/src/__tests__/latch-markers.test.ts
@@ -31,8 +31,8 @@ describe('bodies', () => {
expect(body).toContain(IMG);
});
- test('spent body defaults to the approved wording', () => {
- expect(spentLatchBody(IMG)).toContain('Approved, so this latch is spent');
+ test('spent body is the image alone, with no sentence', () => {
+ expect(spentLatchBody(IMG)).toBe(`${LATCH_MARKER_SPENT}\n\n${IMG}\n`);
});
test('neither body uses an em dash or en dash', () => {
@@ -64,6 +64,12 @@ describe('latchBody', () => {
`${LATCH_MARKER_RUNNING}\n\n${GIF}\n\nIn progress.\n`
);
});
+
+ test('no sentence: marker, blank, image', () => {
+ expect(latchBody('running', GIF)).toBe(
+ `${LATCH_MARKER_RUNNING}\n\n${GIF}\n`
+ );
+ });
});
describe('imageMarkdownFor', () => {
diff --git a/apps/board/src/__tests__/latch-post.test.ts b/apps/board/src/__tests__/latch-post.test.ts
index 71a385462..33646d154 100644
--- a/apps/board/src/__tests__/latch-post.test.ts
+++ b/apps/board/src/__tests__/latch-post.test.ts
@@ -100,7 +100,7 @@ describe('bannerMarkdown', () => {
{ filename: 'latch-2317-armed.gif', contentType: 'image/gif' },
]);
expect(md).toBe(
- ''
+ ''
);
});
@@ -125,12 +125,35 @@ describe('bannerMarkdown', () => {
);
const md = await bannerMarkdown(gw, 42, MR, 2317, { state: 'running' }, db);
const want =
- '';
+ '';
expect(md).toBe(want);
expect(calls).toEqual(['upload:latch-2317-running.gif']);
expect(cachedUpload(MR, 'running', db)).toBe(want);
});
+ test('a banner cached with older copy is uploaded again and replaced', async () => {
+ const db = tempDb();
+ const { gw, calls } = gateway();
+ rememberUpload(
+ MR,
+ 'queued-next-sweep',
+ '',
+ db
+ );
+ const md = await bannerMarkdown(
+ gw,
+ 42,
+ MR,
+ 2317,
+ { state: 'queued', wait: 'next-sweep' },
+ db
+ );
+ expect(md).toBe(
+ ''
+ );
+ expect(calls).toEqual(['upload:latch-2317-queued-next-sweep.gif']);
+ });
+
test('a different variant uploads its own banner', async () => {
const db = tempDb();
const { gw, calls } = gateway();
@@ -262,8 +285,8 @@ describe('spendLatch', () => {
'resolve:d1',
]);
expect(latchKindOf(bodies[0]!)).toBe('spent');
- expect(bodies[0]).toContain('Approved, so this latch is spent');
- expect(bodies[0]).toContain(';
+ expect(bodies[0]).toContain(';
+ expect(bodies[0]!.trimEnd().endsWith(')')).toBe(true);
});
test('an already-spent latch that is resolved draws no writes', async () => {
@@ -315,7 +338,7 @@ describe('spendLatch', () => {
NOW,
db
);
- expect(bodies[0]).toContain('Superseded by a newer latch on this MR');
+ expect(bodies[0]).toContain(';
expect(bodies[0]).not.toContain('Approved');
});
@@ -435,7 +458,7 @@ describe('spendAllLatches', () => {
db
);
expect(
- bodies.every(b => b.includes('Superseded by a newer latch on this MR'))
+ bodies.every(b => b.includes(')
).toBe(true);
});
});
diff --git a/apps/board/src/__tests__/latch-variants.test.ts b/apps/board/src/__tests__/latch-variants.test.ts
index 81767473b..0a3a2a64e 100644
--- a/apps/board/src/__tests__/latch-variants.test.ts
+++ b/apps/board/src/__tests__/latch-variants.test.ts
@@ -133,12 +133,7 @@ describe('copy', () => {
'#86cfa0',
],
[{ state: 'approved' }, 'Approved', 'Nothing more to do', '#86cfa0'],
- [
- { state: 'superseded' },
- 'Superseded',
- 'A newer latch is live',
- '#8e88a0',
- ],
+ [{ state: 'superseded' }, 'Replaced', 'Use the newer thread', '#8e88a0'],
];
for (const [v, title, sub, accent] of rows) {
expect(copyFor(v)).toMatchObject({ title, sub, accent });
@@ -161,28 +156,28 @@ describe('copy', () => {
test('queued subtitles name the wait', () => {
expect(SUB({ state: 'queued', wait: 'next-sweep' })).toBe(
- 'Starts on the next sweep'
+ 'Starts in a few minutes'
);
expect(SUB({ state: 'queued', wait: 'cooldown' })).toBe(
- 'Waiting out the cooldown'
+ 'Waiting between reviews'
);
expect(SUB({ state: 'queued', wait: 'budget' })).toBe(
- 'Daily limit hit; starts tomorrow'
+ 'Daily limit reached. Starts tomorrow'
);
expect(SUB({ state: 'queued', wait: 'review-in-flight' })).toBe(
- 'Waiting for the running review'
+ 'Waiting on another review'
);
});
test('error-retrying subtitles name the wait', () => {
expect(SUB({ state: 'error-retrying', wait: 'next-sweep' })).toBe(
- 'Retrying on the next sweep'
+ 'Trying again in a few minutes'
);
expect(SUB({ state: 'error-retrying', wait: 'cooldown' })).toBe(
- 'Retrying after the cooldown'
+ 'Trying again after a pause'
);
expect(SUB({ state: 'error-retrying', wait: 'budget' })).toBe(
- 'Daily limit hit; retrying tomorrow'
+ 'Daily limit reached. Trying again tomorrow'
);
});
@@ -207,34 +202,27 @@ describe('copy', () => {
);
});
- test('sentences under the image', () => {
+ test('a sentence only where the author has something to do', () => {
expect(copyFor({ state: 'armed' }).sentence).toBe(
"**Addressed everything?** Resolve this thread and I'll take another pass over the MR.\n\nLeave it open while there's still work in flight."
);
- expect(copyFor({ state: 'approved' }).sentence).toBe(
- 'Approved, so this latch is spent. Nothing further to do here.'
- );
- expect(copyFor({ state: 'superseded' }).sentence).toBe(
- 'Superseded by a newer latch on this MR; nothing to do here.'
- );
- expect(copyFor({ state: 'queued', wait: 'budget' }).sentence).toBe(
- "Follow-up review requested. I'll start it as soon as I can."
- );
- expect(copyFor({ state: 'running' }).sentence).toBe(
- "Follow-up review in progress. I'll reply here when it's done."
- );
- expect(
- copyFor({ state: 'error-retrying', wait: 'cooldown' }).sentence
- ).toBe("The follow-up review didn't finish. I'll try again.");
- expect(copyFor({ state: 'error-stuck' }).sentence).toBe(
- "The follow-up review didn't finish after 3 tries. The reviewer has been told."
- );
expect(copyFor({ state: 'done-comments', count: 4 }).sentence).toBe(
"Follow-up review done. Resolve this thread again when you've addressed the new comments."
);
- expect(copyFor({ state: 'done-approved' }).sentence).toBe(
- 'Follow-up review done and approved, so this latch is spent.'
- );
+ for (const key of allVariantKeys()) {
+ const v = variantOfKey(key)!;
+ if (v.state === 'armed' || v.state === 'done-comments') continue;
+ expect(copyFor(v).sentence).toBeUndefined();
+ }
+ });
+
+ test('no posted copy names the latch', () => {
+ for (const key of allVariantKeys()) {
+ const c = copyFor(variantOfKey(key)!);
+ expect(`${c.title} ${c.sub} ${c.sentence ?? ''}`).not.toMatch(
+ /latch|spent|supersed|sweep|cooldown/i
+ );
+ }
});
test('indicator follows the state', () => {
diff --git a/apps/board/src/__tests__/triage-latch.test.ts b/apps/board/src/__tests__/triage-latch.test.ts
index 71debd88a..8f23577c2 100644
--- a/apps/board/src/__tests__/triage-latch.test.ts
+++ b/apps/board/src/__tests__/triage-latch.test.ts
@@ -9,7 +9,6 @@ import {
} from '../latch/latch-state.ts';
import { armedLatchBody, latchBody, spentLatchBody } from '../latch/markers.ts';
import type { LatchGateway } from '../latch/post.ts';
-import { copyFor } from '../latch/variants.ts';
import type { ReviewState } from '../review-state.ts';
import type { AuditEntry } from '../triage/audit.ts';
import { parseTriageBlock } from '../triage/config.ts';
@@ -20,6 +19,7 @@ import {
latchInScope,
runLatchPass,
runStamp,
+ STUCK_REPLY,
waitReasonOf,
type LatchMrFacts,
type LatchPassDeps,
@@ -663,9 +663,7 @@ describe('dedupe', () => {
),
});
await runLatchPass(deps);
- expect(noteBodies.get(1)).toContain(
- 'Superseded by a newer latch on this MR'
- );
+ expect(noteBodies.get(1)).toContain(';
expect(noteBodies.get(1)).not.toContain('Approved');
});
});
@@ -1668,7 +1666,7 @@ describe('error, retry and stuck', () => {
expect((await runLatchPass(deps)).stuck).toBe(1);
expect(uploads).toEqual(['latch-2317-error-stuck.gif']);
expect(calls).toEqual(['upload', 'updateNote:1', 'reply:d1']);
- expect(replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
+ expect(replies).toEqual([STUCK_REPLY]);
expect(stuckNotices).toEqual([
{
title: 'Follow-up review stuck on !2317',
@@ -1804,7 +1802,7 @@ describe('error, retry and stuck', () => {
expect((await runLatchPass(h.deps)).repaired).toBe(1);
expect(h.calls).toEqual(['upload', 'updateNote:1', 'reply:d1']);
expect(h.noteBodies.get(1)).toStartWith(ERROR);
- expect(h.replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
+ expect(h.replies).toEqual([STUCK_REPLY]);
expect(h.stuckNotices).toHaveLength(1);
expect(readLatchRow(MR, db)?.variant).toBe('error-stuck');
@@ -1824,7 +1822,7 @@ describe('error, retry and stuck', () => {
detail(disc('d1', note, '2026-09-01T10:00:00Z', true)),
});
failNextReply(h.deps.gateway);
- const stuck = copyFor({ state: 'error-stuck' }).sentence;
+ const stuck = STUCK_REPLY;
expect((await runLatchPass(h.deps)).stuck).toBe(1);
expect(h.stuckNotices).toHaveLength(1);
expect(h.replies).toEqual([]);
@@ -1857,9 +1855,7 @@ describe('error, retry and stuck', () => {
});
failNextReply(h.deps.gateway);
expect((await runLatchPass(h.deps)).stuck).toBe(1);
- expect(readLatchRow(MR, db)?.replyDue).toBe(
- copyFor({ state: 'error-stuck' }).sentence
- );
+ expect(readLatchRow(MR, db)?.replyDue).toBe(STUCK_REPLY);
note = errorBody();
lane = { ...commented, status: 'reviewing', runStartedAt: NOW };
@@ -1878,7 +1874,7 @@ describe('error, retry and stuck', () => {
test('a stuck latch that gets spent never posts its stuck reply', async () => {
const db = tempDb();
- const stuck = copyFor({ state: 'error-stuck' }).sentence;
+ const stuck = STUCK_REPLY;
putLatchRow(
errorRow({
failures: 3,
@@ -1948,7 +1944,7 @@ describe('error, retry and stuck', () => {
detail: detail(disc('d1', errorBody(), '2026-09-01T10:00:00Z', true)),
});
expect((await runLatchPass(deps)).stuck).toBe(1);
- expect(replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
+ expect(replies).toEqual([STUCK_REPLY]);
expect(stuckNotices).toHaveLength(1);
expect(memory.mrs[MR]?.attemptsToday ?? 0).toBe(0);
expect(readLatchRow(MR, db)).toMatchObject({
@@ -2053,7 +2049,7 @@ describe('error, retry and stuck', () => {
return update(p, i, id, body);
};
expect((await runLatchPass(h.deps)).failed).toBe(1);
- expect(h.replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
+ expect(h.replies).toEqual([STUCK_REPLY]);
expect(readLatchRow(MR, db)?.variant).toBe('error-stuck');
});
});
diff --git a/apps/board/src/latch/markers.ts b/apps/board/src/latch/markers.ts
index c90641e02..61419a087 100644
--- a/apps/board/src/latch/markers.ts
+++ b/apps/board/src/latch/markers.ts
@@ -29,11 +29,6 @@ export const ARMED_COPY = [
"Leave it open while there's still work in flight.",
].join('\n');
-export const SPENT_COPY =
- 'Approved, so this latch is spent. Nothing further to do here.';
-export const DUPLICATE_COPY =
- 'Superseded by a newer latch on this MR; nothing to do here.';
-
// Typed by LatchKind, not latch-state's LatchPhase: the client bundle reaches
// this file, and latch-state pulls in bun:sqlite.
const PHASE_MARKER: Record = {
@@ -47,9 +42,11 @@ const PHASE_MARKER: Record = {
export function latchBody(
phase: LatchKind,
imageMarkdown: string,
- sentence: string
+ sentence?: string
): string {
- return `${PHASE_MARKER[phase]}\n\n${imageMarkdown}\n\n${sentence}\n`;
+ const parts = [PHASE_MARKER[phase], imageMarkdown];
+ if (sentence) parts.push(sentence);
+ return `${parts.join('\n\n')}\n`;
}
export function armedLatchBody(imageMarkdown: string): string {
@@ -57,7 +54,7 @@ export function armedLatchBody(imageMarkdown: string): string {
}
export function spentLatchBody(imageMarkdown: string): string {
- return latchBody('spent', imageMarkdown, SPENT_COPY);
+ return latchBody('spent', imageMarkdown);
}
/** Unsized on purpose: GitLab stretches the image to the comment column. */
diff --git a/apps/board/src/latch/post.ts b/apps/board/src/latch/post.ts
index 1dfdbf8eb..76e546422 100644
--- a/apps/board/src/latch/post.ts
+++ b/apps/board/src/latch/post.ts
@@ -15,7 +15,12 @@ import {
type LatchPhase,
} from './latch-state.ts';
import { imageMarkdownFor, latchBody } from './markers.ts';
-import { copyFor, variantKey, type BannerVariant } from './variants.ts';
+import {
+ bannerAlt,
+ copyFor,
+ variantKey,
+ type BannerVariant,
+} from './variants.ts';
export interface LatchGateway {
uploadFile(
@@ -84,16 +89,19 @@ export async function bannerMarkdown(
db: Database = getStateDb()
): Promise {
const key = variantKey(variant);
+ const alt = bannerAlt(variant);
const cached = cachedUpload(mrUrl, key, db);
- // A sized row is a pre-3x upload; it is replaced, not reused.
- if (cached && !cached.includes('{width=')) return cached;
+ // A row whose alt text no longer matches was drawn with older copy (or is a
+ // sized pre-3x upload): it is replaced, not reused.
+ if (cached?.startsWith(` && !cached.includes('{width='))
+ return cached;
const up = await gw.uploadFile(
projectId,
`latch-${iid}-${key}.gif`,
latchBannerGif(mrUrl, variant),
'image/gif'
);
- const markdown = imageMarkdownFor(copyFor(variant).title, up.markdown);
+ const markdown = imageMarkdownFor(alt, up.markdown);
rememberUpload(mrUrl, key, markdown, db);
return markdown;
}
diff --git a/apps/board/src/latch/variants.ts b/apps/board/src/latch/variants.ts
index d8a4b826e..6adf706ef 100644
--- a/apps/board/src/latch/variants.ts
+++ b/apps/board/src/latch/variants.ts
@@ -1,11 +1,11 @@
/**
* Every banner the latch can show, keyed so each one maps to a committed
* text strip (assets/latch/.png), plus the copy painted on the strip
- * and the plain sentence posted under it.
+ * and, when the author has something to do, the sentence posted under it.
*/
import type { WaitReason } from './latch-state.ts';
-import { ARMED_COPY, DUPLICATE_COPY, SPENT_COPY } from './markers.ts';
+import { ARMED_COPY } from './markers.ts';
export type BannerState =
| 'armed'
@@ -31,7 +31,7 @@ export interface VariantCopy {
sub: string;
accent: string;
indicator: Indicator;
- sentence: string;
+ sentence?: string;
}
const STATES: BannerState[] = [
@@ -47,16 +47,16 @@ const STATES: BannerState[] = [
];
const QUEUED_SUB: Record = {
- 'next-sweep': 'Starts on the next sweep',
- cooldown: 'Waiting out the cooldown',
- budget: 'Daily limit hit; starts tomorrow',
- 'review-in-flight': 'Waiting for the running review',
+ 'next-sweep': 'Starts in a few minutes',
+ cooldown: 'Waiting between reviews',
+ budget: 'Daily limit reached. Starts tomorrow',
+ 'review-in-flight': 'Waiting on another review',
};
const RETRY_SUB: Partial> = {
- 'next-sweep': 'Retrying on the next sweep',
- cooldown: 'Retrying after the cooldown',
- budget: 'Daily limit hit; retrying tomorrow',
+ 'next-sweep': 'Trying again in a few minutes',
+ cooldown: 'Trying again after a pause',
+ budget: 'Daily limit reached. Trying again tomorrow',
};
const WAITS = Object.keys(QUEUED_SUB) as WaitReason[];
@@ -136,6 +136,12 @@ function doneCommentsSub(raw: number | null | undefined): string {
return `${count} new comments. Resolve when ready`;
}
+/** The banner's alt text: what the image says, for a reader who can't see it. */
+export function bannerAlt(v: BannerVariant): string {
+ const { title, sub } = copyFor(v);
+ return `${title}${/[.?!]$/.test(title) ? '' : '.'} ${sub}`;
+}
+
export function copyFor(v: BannerVariant): VariantCopy {
switch (v.state) {
case 'armed':
@@ -152,7 +158,6 @@ export function copyFor(v: BannerVariant): VariantCopy {
sub: QUEUED_SUB[v.wait ?? 'next-sweep'],
accent: '#e6b860',
indicator: 'dots',
- sentence: "Follow-up review requested. I'll start it as soon as I can.",
};
case 'running':
return {
@@ -160,16 +165,13 @@ export function copyFor(v: BannerVariant): VariantCopy {
sub: 'Results post here when done',
accent: '#7fbfe8',
indicator: 'ring',
- sentence:
- "Follow-up review in progress. I'll reply here when it's done.",
};
case 'error-retrying':
return {
title: 'Follow-up review hit a snag',
- sub: (v.wait && RETRY_SUB[v.wait]) || 'Retrying on the next sweep',
+ sub: (v.wait && RETRY_SUB[v.wait]) || 'Trying again in a few minutes',
accent: '#f2917e',
indicator: 'dots',
- sentence: "The follow-up review didn't finish. I'll try again.",
};
case 'error-stuck':
return {
@@ -177,8 +179,6 @@ export function copyFor(v: BannerVariant): VariantCopy {
sub: 'Reviewer notified',
accent: '#f2917e',
indicator: 'none',
- sentence:
- "The follow-up review didn't finish after 3 tries. The reviewer has been told.",
};
case 'done-comments':
return {
@@ -195,7 +195,6 @@ export function copyFor(v: BannerVariant): VariantCopy {
sub: 'Approved. Nothing more to do',
accent: '#86cfa0',
indicator: 'none',
- sentence: 'Follow-up review done and approved, so this latch is spent.',
};
case 'approved':
return {
@@ -203,15 +202,13 @@ export function copyFor(v: BannerVariant): VariantCopy {
sub: 'Nothing more to do',
accent: '#86cfa0',
indicator: 'none',
- sentence: SPENT_COPY,
};
case 'superseded':
return {
- title: 'Superseded',
- sub: 'A newer latch is live',
+ title: 'Replaced',
+ sub: 'Use the newer thread',
accent: '#8e88a0',
indicator: 'none',
- sentence: DUPLICATE_COPY,
};
}
}
diff --git a/apps/board/src/triage/latch.ts b/apps/board/src/triage/latch.ts
index 48f426e6c..6e886a66f 100644
--- a/apps/board/src/triage/latch.ts
+++ b/apps/board/src/triage/latch.ts
@@ -41,7 +41,6 @@ import {
type LatchTarget,
} from '../latch/post.ts';
import {
- copyFor,
variantKey,
variantOfKey,
type BannerVariant,
@@ -157,7 +156,8 @@ export const LATCH_MAX_FAILURES = 3;
export const LATCH_REPLY_GIVE_UP_MS = 24 * 60 * 60_000;
const STUCK: BannerVariant = { state: 'error-stuck' };
-const STUCK_REPLY = copyFor(STUCK).sentence;
+export const STUCK_REPLY =
+ "The follow-up review didn't finish after 3 tries. The reviewer has been told.";
/** The wait a decision maps to, or null for a refusal that never clears by
itself. */