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( - '![Ready for another pass?](/uploads/ab12/latch-2317-armed.gif)' + '![Ready for another pass? Resolve this thread to ask](/uploads/ab12/latch-2317-armed.gif)' ); }); @@ -125,12 +125,35 @@ describe('bannerMarkdown', () => { ); const md = await bannerMarkdown(gw, 42, MR, 2317, { state: 'running' }, db); const want = - '![Follow-up review running](/uploads/ab12/latch-2317-running.gif)'; + '![Follow-up review running. Results post here when done](/uploads/ab12/latch-2317-running.gif)'; 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', + '![Follow-up review queued. Starts on the next sweep](/uploads/old1/latch-2317-queued-next-sweep.gif)', + db + ); + const md = await bannerMarkdown( + gw, + 42, + MR, + 2317, + { state: 'queued', wait: 'next-sweep' }, + db + ); + expect(md).toBe( + '![Follow-up review queued. Starts in a few minutes](/uploads/ab12/latch-2317-queued-next-sweep.gif)' + ); + 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('![Approved]('); + expect(bodies[0]).toContain('![Approved. Nothing more to do]('); + 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('![Replaced. Use the newer thread]('); 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('![Replaced. Use the newer thread](')) ).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('![Replaced. Use the newer thread]('); 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(`![${alt}](`) && !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. */