diff --git a/apps/board/assets/latch/approved.png b/apps/board/assets/latch/approved.png index 6db1f21c9..eceae7bd0 100644 Binary files a/apps/board/assets/latch/approved.png and b/apps/board/assets/latch/approved.png differ diff --git a/apps/board/assets/latch/armed.png b/apps/board/assets/latch/armed.png index c1fcd6371..9f1e082ed 100644 Binary files a/apps/board/assets/latch/armed.png and b/apps/board/assets/latch/armed.png differ diff --git a/apps/board/assets/latch/done-approved.png b/apps/board/assets/latch/done-approved.png index 32d2d05b2..89922c320 100644 Binary files a/apps/board/assets/latch/done-approved.png and b/apps/board/assets/latch/done-approved.png differ diff --git a/apps/board/assets/latch/done-comments-1.png b/apps/board/assets/latch/done-comments-1.png index 34fabbffb..e745d5fd8 100644 Binary files a/apps/board/assets/latch/done-comments-1.png and b/apps/board/assets/latch/done-comments-1.png differ diff --git a/apps/board/assets/latch/done-comments-10.png b/apps/board/assets/latch/done-comments-10.png index 893be4e0b..a04016208 100644 Binary files a/apps/board/assets/latch/done-comments-10.png and b/apps/board/assets/latch/done-comments-10.png differ diff --git a/apps/board/assets/latch/done-comments-11.png b/apps/board/assets/latch/done-comments-11.png index 399b3ce85..cc66926e4 100644 Binary files a/apps/board/assets/latch/done-comments-11.png and b/apps/board/assets/latch/done-comments-11.png differ diff --git a/apps/board/assets/latch/done-comments-12.png b/apps/board/assets/latch/done-comments-12.png index 5906af8dd..0f57ecd78 100644 Binary files a/apps/board/assets/latch/done-comments-12.png and b/apps/board/assets/latch/done-comments-12.png differ diff --git a/apps/board/assets/latch/done-comments-13.png b/apps/board/assets/latch/done-comments-13.png index 2ddab02d3..988bf4e80 100644 Binary files a/apps/board/assets/latch/done-comments-13.png and b/apps/board/assets/latch/done-comments-13.png differ diff --git a/apps/board/assets/latch/done-comments-14.png b/apps/board/assets/latch/done-comments-14.png index deb2bed74..43becab42 100644 Binary files a/apps/board/assets/latch/done-comments-14.png and b/apps/board/assets/latch/done-comments-14.png differ diff --git a/apps/board/assets/latch/done-comments-15.png b/apps/board/assets/latch/done-comments-15.png index 91669f0f5..8cfc17c22 100644 Binary files a/apps/board/assets/latch/done-comments-15.png and b/apps/board/assets/latch/done-comments-15.png differ diff --git a/apps/board/assets/latch/done-comments-16.png b/apps/board/assets/latch/done-comments-16.png index 3acac150d..a8c87bf2c 100644 Binary files a/apps/board/assets/latch/done-comments-16.png and b/apps/board/assets/latch/done-comments-16.png differ diff --git a/apps/board/assets/latch/done-comments-17.png b/apps/board/assets/latch/done-comments-17.png index 569f36f55..fc3ff84e3 100644 Binary files a/apps/board/assets/latch/done-comments-17.png and b/apps/board/assets/latch/done-comments-17.png differ diff --git a/apps/board/assets/latch/done-comments-18.png b/apps/board/assets/latch/done-comments-18.png index 106b6aa09..ec93aee8a 100644 Binary files a/apps/board/assets/latch/done-comments-18.png and b/apps/board/assets/latch/done-comments-18.png differ diff --git a/apps/board/assets/latch/done-comments-19.png b/apps/board/assets/latch/done-comments-19.png index ac514c8d1..814d98482 100644 Binary files a/apps/board/assets/latch/done-comments-19.png and b/apps/board/assets/latch/done-comments-19.png differ diff --git a/apps/board/assets/latch/done-comments-2.png b/apps/board/assets/latch/done-comments-2.png index 3f9f20314..e2cc80d02 100644 Binary files a/apps/board/assets/latch/done-comments-2.png and b/apps/board/assets/latch/done-comments-2.png differ diff --git a/apps/board/assets/latch/done-comments-20.png b/apps/board/assets/latch/done-comments-20.png index 9ea7fd5b8..7b84e8a48 100644 Binary files a/apps/board/assets/latch/done-comments-20.png and b/apps/board/assets/latch/done-comments-20.png differ diff --git a/apps/board/assets/latch/done-comments-20plus.png b/apps/board/assets/latch/done-comments-20plus.png index f849e0eca..10e0385f4 100644 Binary files a/apps/board/assets/latch/done-comments-20plus.png and b/apps/board/assets/latch/done-comments-20plus.png differ diff --git a/apps/board/assets/latch/done-comments-3.png b/apps/board/assets/latch/done-comments-3.png index ff6e8c85b..6217a61b4 100644 Binary files a/apps/board/assets/latch/done-comments-3.png and b/apps/board/assets/latch/done-comments-3.png differ diff --git a/apps/board/assets/latch/done-comments-4.png b/apps/board/assets/latch/done-comments-4.png index eb2bf1534..e7fbf64c0 100644 Binary files a/apps/board/assets/latch/done-comments-4.png and b/apps/board/assets/latch/done-comments-4.png differ diff --git a/apps/board/assets/latch/done-comments-5.png b/apps/board/assets/latch/done-comments-5.png index fea99577e..6f1a3d0c4 100644 Binary files a/apps/board/assets/latch/done-comments-5.png and b/apps/board/assets/latch/done-comments-5.png differ diff --git a/apps/board/assets/latch/done-comments-6.png b/apps/board/assets/latch/done-comments-6.png index f295af565..9a3fbea15 100644 Binary files a/apps/board/assets/latch/done-comments-6.png and b/apps/board/assets/latch/done-comments-6.png differ diff --git a/apps/board/assets/latch/done-comments-7.png b/apps/board/assets/latch/done-comments-7.png index 0d333bf21..e829ee6c1 100644 Binary files a/apps/board/assets/latch/done-comments-7.png and b/apps/board/assets/latch/done-comments-7.png differ diff --git a/apps/board/assets/latch/done-comments-8.png b/apps/board/assets/latch/done-comments-8.png index c517c41ac..a3e072d1c 100644 Binary files a/apps/board/assets/latch/done-comments-8.png and b/apps/board/assets/latch/done-comments-8.png differ diff --git a/apps/board/assets/latch/done-comments-9.png b/apps/board/assets/latch/done-comments-9.png index 63ecae1e1..733f9a525 100644 Binary files a/apps/board/assets/latch/done-comments-9.png and b/apps/board/assets/latch/done-comments-9.png differ diff --git a/apps/board/assets/latch/done-comments-none.png b/apps/board/assets/latch/done-comments-none.png index fe248bba9..fa21e21b4 100644 Binary files a/apps/board/assets/latch/done-comments-none.png and b/apps/board/assets/latch/done-comments-none.png differ diff --git a/apps/board/assets/latch/error-retrying-budget.png b/apps/board/assets/latch/error-retrying-budget.png index 32efaf651..a7c00b483 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 6c00964bf..924056757 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 cf965d61a..a28aa0e70 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/error-stuck.png b/apps/board/assets/latch/error-stuck.png index f8cb693db..820ad9808 100644 Binary files a/apps/board/assets/latch/error-stuck.png and b/apps/board/assets/latch/error-stuck.png differ diff --git a/apps/board/assets/latch/layout.json b/apps/board/assets/latch/layout.json index c2debc4d4..95a63547f 100644 --- a/apps/board/assets/latch/layout.json +++ b/apps/board/assets/latch/layout.json @@ -1,142 +1,142 @@ { "armed": { - "dotsX": 777, - "dotsY": 52 + "dotsX": 1166, + "dotsY": 78 }, "queued-next-sweep": { - "dotsX": 759, - "dotsY": 52 + "dotsX": 1139, + "dotsY": 78 }, "queued-cooldown": { - "dotsX": 759, - "dotsY": 52 + "dotsX": 1139, + "dotsY": 78 }, "queued-budget": { - "dotsX": 759, - "dotsY": 52 + "dotsX": 1139, + "dotsY": 78 }, "queued-review-in-flight": { - "dotsX": 759, - "dotsY": 52 + "dotsX": 1139, + "dotsY": 78 }, "running": { - "dotsX": 797, - "dotsY": 52 + "dotsX": 1196, + "dotsY": 78 }, "error-retrying-next-sweep": { - "dotsX": 853, - "dotsY": 52 + "dotsX": 1279, + "dotsY": 78 }, "error-retrying-cooldown": { - "dotsX": 853, - "dotsY": 52 + "dotsX": 1279, + "dotsY": 78 }, "error-retrying-budget": { - "dotsX": 853, - "dotsY": 52 + "dotsX": 1279, + "dotsY": 78 }, "error-stuck": { - "dotsX": 735, - "dotsY": 52 + "dotsX": 1102, + "dotsY": 78 }, "done-comments-1": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-2": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-3": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-4": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-5": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-6": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-7": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-8": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-9": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-10": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-11": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-12": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-13": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-14": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-15": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-16": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-17": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-18": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-19": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-20": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-20plus": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-comments-none": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "done-approved": { - "dotsX": 710, - "dotsY": 52 + "dotsX": 1065, + "dotsY": 78 }, "approved": { - "dotsX": 378, - "dotsY": 52 + "dotsX": 567, + "dotsY": 78 }, "superseded": { - "dotsX": 423, - "dotsY": 52 + "dotsX": 634, + "dotsY": 78 } } diff --git a/apps/board/assets/latch/queued-budget.png b/apps/board/assets/latch/queued-budget.png index e2f263b1f..62b898282 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 27034c552..ef480ef0c 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 c557bb475..ccbe642e5 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 feacec522..1693a6a14 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/running.png b/apps/board/assets/latch/running.png index e2c89b15e..78046464d 100644 Binary files a/apps/board/assets/latch/running.png and b/apps/board/assets/latch/running.png differ diff --git a/apps/board/assets/latch/superseded.png b/apps/board/assets/latch/superseded.png index 2b777c87f..655c0be0b 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 b01e8a9d4..5db50c782 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 @@ -20,7 +20,7 @@ smaller and calmer than today's banner. - One banner per latch state, swapped on the same note as the state changes: armed, queued, running, error, done (comments or approved). - Each banner is an animated GIF strip: this MR's invadr creature plus - short copy, 520 CSS px wide. + short copy, stretched to the width of the GitLab comment column. - A follow-up the board cannot start yet waits in queued instead of being refused with "Not yet". - A follow-up that fails is retried by the board; after three failures it @@ -44,10 +44,11 @@ smaller and calmer than today's banner. ## The states -Every banner is a 520x64 strip (1040x128 pixels, 2x for Retina) on -`#2a2638`, the creature on the left, a title in Silkscreen 18 (`#ece8f4`) -and a subtitle in IBM Plex Mono 13 (`#b7b0c8`). The accent colours the -creature and its indicator. +Every banner is laid out as a 520x64 CSS px strip and rasterised at 3x +(1560x192 pixels, so it stays sharp at full column width on Retina) on +`#2a2638`, with 6 CSS px rounded corners, the creature on the left, a +title in Silkscreen 18 (`#ece8f4`) and a subtitle in IBM Plex Mono 13 +(`#b7b0c8`). The accent colours the creature and its indicator. | State | Marker kind | Thread | Title | Subtitle | Accent | Motion | |---|---|---|---|---|---|---| @@ -229,9 +230,26 @@ It reads the strip, paints this MR's creature from the prototype: armed ~4.4s loop, queued 420ms ticks, running 130ms frames, done and error ~3.5s loops. -The posted markdown is `![](){width=520}` so -GitLab shows it at 520 CSS px instead of stretching it to the column. -`imageMarkdownOf` must keep matching with that suffix. +The posted markdown is `![]()`, unsized, so +GitLab stretches it to the comment column (about 785 CSS px in a typical +window, where the 1560 px GIF shows at about 2x). `imageMarkdownOf` must +keep matching the older `{width=N}` suffix. A cached upload with that +suffix is a pre-3x GIF, so it counts as a cache miss: the banner is +uploaded again and the cached row replaced. + +The corners are a binary quarter-circle mask (GIF transparency has no +alpha): one palette slot is reserved as the transparent index, every +pixel outside the rounded rect takes it in every frame, and every frame +is whole with dispose 1, so the corners never flash. The reserved slot +holds the strip background (`#2a2638`), so a viewer that ignores +transparency shows square corners in the banner colour rather than black. The compositor +applies the mask, not the strips. + +The geometry lives in CSS px and is multiplied by the strip set's scale +(3 for the shipped strips). The parity test composites at 2x with 2x +strips the build script renders into its fixtures, so it compares +pixel-for-pixel with the approved 1040x128 prototype GIFs, the rounded +corners excluded. Alternative B (one committed GIF per state, same creature for every MR) was rejected: it drops the per-MR creature for a simpler runtime. @@ -240,7 +258,7 @@ the compiled binary. ## Uploads -Every state change uploads the new GIF (30-60 KB) and rewrites the note. +Every state change uploads the new GIF (50-80 KB) and rewrites the note. The board caches each upload's markdown per MR and state variant in its state db and reuses it when the latch returns to a variant it has shown (error, then running again). Existing latches keep their old PNG until @@ -281,6 +299,7 @@ their next state change, then get the new banner. ordering cases (server signal and pass racing, retry after error, approval from each state). - A banner test that each state encodes, has the expected frame count, - stays under 80 KB, and is 1040x128. + stays under 100 KB, is 1560x192, and is transparent outside its + rounded corners in every frame. - Render every state and wait reason into a preview page and look at it in GitLab light and dark styling before calling it done. diff --git a/apps/board/scripts/build-latch-band.ts b/apps/board/scripts/build-latch-band.ts index bd04de5c8..efc063d6f 100644 --- a/apps/board/scripts/build-latch-band.ts +++ b/apps/board/scripts/build-latch-band.ts @@ -8,25 +8,33 @@ * bun run scripts/build-latch-band.ts * * Also writes assets/latch/layout.json (where the runtime paints the dots, - * in 2x px) and assets/latch/index.ts, which embeds each strip by file: - * `bun build --compile` embeds single files, never a directory. + * in BANNER_SCALE px) and assets/latch/index.ts, which embeds each strip by + * file: `bun build --compile` embeds single files, never a directory. The + * parity variants are rendered a second time at the approved prototype's + * 2x into the parity test's fixtures. */ import { mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { chromium } from 'playwright'; +import { chromium, type Browser } from 'playwright'; import { format, resolveConfig } from 'prettier'; +import { + PARITY_CASES, + PARITY_SCALE, + PARITY_STRIPS_DIR, +} from '../src/__tests__/fixtures/latch-parity.ts'; import { allVariantKeys, + BANNER_SCALE, copyFor, DOT_GAP_PX, DOT_PX, + BANNER_CSS_H as H, + variantKey, variantOfKey, + BANNER_CSS_W as W, } from '../src/latch/variants.ts'; -const W = 520; -const H = 64; -const SCALE = 2; const OUT = join(import.meta.dir, '..', 'assets', 'latch'); const escape = (s: string) => @@ -43,90 +51,113 @@ const html = ` .copy { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; } /* The title row lays the dots out as the approved prototype did: flex children 4px apart, bottom-aligned 2px above the title's line box. */ - .titlerow { display: flex; align-items: flex-end; gap: ${DOT_GAP_PX / SCALE}px; } + .titlerow { display: flex; align-items: flex-end; gap: ${DOT_GAP_PX}px; } .title { font-family: 'Silkscreen'; font-size: 18px; letter-spacing: 1px; color: #ece8f4; line-height: 1; white-space: nowrap; } - .dot { width: ${DOT_PX / SCALE}px; height: ${DOT_PX / SCALE}px; margin-bottom: 2px; flex: none; } + .dot { width: ${DOT_PX}px; height: ${DOT_PX}px; margin-bottom: 2px; flex: none; } .sub { font-family: 'IBM Plex Mono'; font-size: 13px; color: #b7b0c8; line-height: 1; white-space: nowrap; } .ring { width: 22px; height: 22px; flex: none; }
`; -const browser = await chromium.launch(); -const page = await browser.newPage({ - viewport: { width: W, height: H }, - deviceScaleFactor: SCALE, -}); -await page.setContent(html, { waitUntil: 'networkidle' }); -// FontFaceSet.check() is true when no face is registered at all, which is -// what a failed stylesheet fetch leaves, so require each load to find a face. -const fontsOk = await page.evaluate(async () => { - const fonts = ( - globalThis as unknown as { - document: { fonts: { load(f: string): Promise<{ status: string }[]> } }; - } - ).document.fonts; - const silkscreen = await fonts.load("18px 'Silkscreen'"); - const plex = await fonts.load("13px 'IBM Plex Mono'"); - return [silkscreen, plex].every( - faces => faces.length > 0 && faces.every(f => f.status === 'loaded') - ); -}); -if (!fontsOk) { - await browser.close(); - throw new Error( - 'Google Fonts did not load; refusing to render strips with fallback fonts' - ); -} +type Layout = Record; -mkdirSync(OUT, { recursive: true }); -const keys = allVariantKeys(); -const layout: Record = {}; +async function renderStrips( + browser: Browser, + scale: number, + keys: string[], + out: string +): Promise { + const page = await browser.newPage({ + viewport: { width: W, height: H }, + deviceScaleFactor: scale, + }); + await page.setContent(html, { waitUntil: 'networkidle' }); + // FontFaceSet.check() is true when no face is registered at all, which is + // what a failed stylesheet fetch leaves, so require each load to find a face. + const fontsOk = await page.evaluate(async () => { + const fonts = ( + globalThis as unknown as { + document: { + fonts: { load(f: string): Promise<{ status: string }[]> }; + }; + } + ).document.fonts; + const silkscreen = await fonts.load("18px 'Silkscreen'"); + const plex = await fonts.load("13px 'IBM Plex Mono'"); + return [silkscreen, plex].every( + faces => faces.length > 0 && faces.every(f => f.status === 'loaded') + ); + }); + if (!fontsOk) { + await browser.close(); + throw new Error( + 'Google Fonts did not load; refusing to render strips with fallback fonts' + ); + } -for (const key of keys) { - const copy = copyFor(variantOfKey(key)!); - const inner = `
+ mkdirSync(out, { recursive: true }); + const layout: Layout = {}; + for (const key of keys) { + const copy = copyFor(variantOfKey(key)!); + const inner = `
${escape( copy.title.toUpperCase() )}
${'
'.repeat(3)}
${escape(copy.sub)}
${ copy.indicator === 'ring' ? '
' : '' }`; - const m = await page.evaluate(markup => { - const doc = (globalThis as unknown as { document: any }).document; - const band = doc.getElementById('band'); - band.innerHTML = markup; - const box = (sel: string) => - band.querySelector(sel).getBoundingClientRect(); - const copyBox = box('.copy'); - const dots = [...band.querySelectorAll('.dot')].map((d: any) => - d.getBoundingClientRect() - ); - const sub = box('.sub'); - return { - copyRight: copyBox.right, - titleRight: box('.title').right, - dotsLeft: dots[0].left, - dotsTop: dots[0].top, - dotsRight: dots[2].right, - subRight: sub.right, - }; - }, inner); + const m = await page.evaluate(markup => { + const doc = (globalThis as unknown as { document: any }).document; + const band = doc.getElementById('band'); + band.innerHTML = markup; + const box = (sel: string) => + band.querySelector(sel).getBoundingClientRect(); + const copyBox = box('.copy'); + const dots = [...band.querySelectorAll('.dot')].map((d: any) => + d.getBoundingClientRect() + ); + const sub = box('.sub'); + return { + copyRight: copyBox.right, + titleRight: box('.title').right, + dotsLeft: dots[0].left, + dotsTop: dots[0].top, + dotsRight: dots[2].right, + subRight: sub.right, + }; + }, inner); - const titleEnd = copy.indicator === 'dots' ? m.dotsRight : m.titleRight; - if (titleEnd > m.copyRight || m.subRight > m.copyRight) { - throw new Error(`${key}: copy overflows its box`); + const titleEnd = copy.indicator === 'dots' ? m.dotsRight : m.titleRight; + if (titleEnd > m.copyRight || m.subRight > m.copyRight) { + throw new Error(`${key}: copy overflows its box`); + } + layout[key] = { + dotsX: Math.round(m.dotsLeft * scale), + dotsY: Math.round(m.dotsTop * scale), + }; + await page.locator('#band').screenshot({ path: join(out, `${key}.png`) }); } - layout[key] = { - dotsX: Math.round(m.dotsLeft * SCALE), - dotsY: Math.round(m.dotsTop * SCALE), - }; - await page.locator('#band').screenshot({ path: join(OUT, `${key}.png`) }); + await page.close(); + writeFileSync( + join(out, 'layout.json'), + `${JSON.stringify(layout, null, 2)}\n` + ); + return layout; } + +const browser = await chromium.launch(); +const keys = allVariantKeys(); +await renderStrips(browser, BANNER_SCALE, keys, OUT); +await renderStrips( + browser, + PARITY_SCALE, + PARITY_CASES.map(([, v]) => variantKey(v)), + PARITY_STRIPS_DIR +); await browser.close(); -writeFileSync(join(OUT, 'layout.json'), `${JSON.stringify(layout, null, 2)}\n`); const imports = keys.map( (k, i) => `import s${i} from './${k}.png' with { type: 'file' };` ); @@ -152,5 +183,5 @@ writeFileSync( }) ); console.log( - `wrote ${keys.length} strips to assets/latch (${W * SCALE}x${H * SCALE})` + `wrote ${keys.length} strips to assets/latch (${W * BANNER_SCALE}x${H * BANNER_SCALE}) and ${PARITY_CASES.length} parity strips (${W * PARITY_SCALE}x${H * PARITY_SCALE})` ); diff --git a/apps/board/scripts/preview-latch-banners.ts b/apps/board/scripts/preview-latch-banners.ts index ba2e7993f..a9f6dd454 100644 --- a/apps/board/scripts/preview-latch-banners.ts +++ b/apps/board/scripts/preview-latch-banners.ts @@ -2,7 +2,8 @@ * Writes a static preview of every latch banner to a temp dir and prints its * path. Two sections: the seven approved prototype GIFs beside the runtime's * rendering of the same creature, then every variant as a GitLab comment - * would show it. light.html and dark.html hold the two page schemes. + * would show it: unsized, so stretched to the comment column. light.html and + * dark.html hold the two page schemes. * * bun run scripts/preview-latch-banners.ts */ @@ -10,34 +11,18 @@ import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { + PARITY_CASES, + REFERENCE_DIR as REFERENCE, +} from '../src/__tests__/fixtures/latch-parity.ts'; import { PROTOTYPE_CREATURE } from '../src/__tests__/fixtures/latch-prototype-creature.ts'; import { latchBannerGif, renderBannerGif } from '../src/latch/banner.ts'; import { allVariantKeys, copyFor, variantOfKey, - type BannerVariant, } from '../src/latch/variants.ts'; -const REFERENCE = join( - import.meta.dir, - '..', - 'src', - '__tests__', - 'fixtures', - 'latch-reference' -); - -const PARITY: [string, BannerVariant][] = [ - ['armed', { state: 'armed' }], - ['queued', { state: 'queued', wait: 'next-sweep' }], - ['running', { state: 'running' }], - ['done-comments', { state: 'done-comments', count: 3 }], - ['done-approved', { state: 'done-approved' }], - ['error-retrying', { state: 'error-retrying', wait: 'next-sweep' }], - ['error-stuck', { state: 'error-stuck' }], -]; - const MR = 'https://gitlab.example.com/acme/widgets/-/merge_requests/42'; const EXTRA_ARMED = [ 'https://gitlab.example.com/acme/widgets/-/merge_requests/108', @@ -87,12 +72,12 @@ function card(src: string, sentence: string, caption?: string): string { return `
${caption ? `
${esc(caption)}
` : ''}
Board reviewer @reviewer, just now
- + ${markdown(sentence)}
`; } -const parityRows = PARITY.map(([name, variant]) => { +const parityRows = PARITY_CASES.map(([name, variant]) => { copyFileSync( join(REFERENCE, `latch-${name}.gif`), join(out, 'gifs', `approved-${name}.gif`) @@ -134,11 +119,11 @@ function page(scheme: keyof typeof SCHEMES): string { body { margin: 0; padding: 24px; background: ${c.page}; color: ${c.text}; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } h2 { margin: 32px 0 8px; } h3 { margin: 20px 0 6px; font-size: 13px; color: ${c.muted}; } - .pair { display: flex; gap: 16px; flex-wrap: wrap; } + .pair { display: flex; flex-direction: column; gap: 16px; } .grid { display: flex; flex-direction: column; gap: 16px; } - .card { margin: 0; width: 552px; box-sizing: border-box; padding: 12px 16px; + .card { margin: 0; width: 820px; box-sizing: border-box; padding: 12px 16px; background: ${c.card}; border: 1px solid ${c.border}; border-radius: 4px; } - .card img { display: block; width: 520px; height: auto; margin: 8px 0; } + .card img { display: block; max-width: 100%; height: auto; margin: 8px 0; } .card p { margin: 0; } .card p + p { margin-top: 8px; } figcaption { font: 600 11px/1 ui-monospace, monospace; color: ${c.muted}; margin-bottom: 8px; } diff --git a/apps/board/src/__tests__/fixtures/latch-parity.ts b/apps/board/src/__tests__/fixtures/latch-parity.ts new file mode 100644 index 000000000..7952aeb79 --- /dev/null +++ b/apps/board/src/__tests__/fixtures/latch-parity.ts @@ -0,0 +1,37 @@ +/** + * The variants the approved prototype GIFs (latch-reference/) show, and the + * 2x strips scripts/build-latch-band.ts renders for them (latch-strips-2x/), + * so the parity test can composite at the prototype's scale. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import type { StripLayout, StripSet } from '../../latch/banner.ts'; +import { variantKey, type BannerVariant } from '../../latch/variants.ts'; + +export const REFERENCE_DIR = join(import.meta.dir, 'latch-reference'); +export const PARITY_STRIPS_DIR = join(import.meta.dir, 'latch-strips-2x'); +export const PARITY_SCALE = 2; + +export const PARITY_CASES: [string, BannerVariant][] = [ + ['armed', { state: 'armed' }], + ['queued', { state: 'queued', wait: 'next-sweep' }], + ['running', { state: 'running' }], + ['done-comments', { state: 'done-comments', count: 3 }], + ['done-approved', { state: 'done-approved' }], + ['error-retrying', { state: 'error-retrying', wait: 'next-sweep' }], + ['error-stuck', { state: 'error-stuck' }], +]; + +export function parityStrips(): StripSet { + const keys = PARITY_CASES.map(([, v]) => variantKey(v)); + return { + scale: PARITY_SCALE, + strips: Object.fromEntries( + keys.map(k => [k, join(PARITY_STRIPS_DIR, `${k}.png`)]) + ), + layout: JSON.parse( + readFileSync(join(PARITY_STRIPS_DIR, 'layout.json'), 'utf8') + ) as Record, + }; +} diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/armed.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/armed.png new file mode 100644 index 000000000..c1fcd6371 Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/armed.png differ diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/done-approved.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/done-approved.png new file mode 100644 index 000000000..32d2d05b2 Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/done-approved.png differ diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/done-comments-3.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/done-comments-3.png new file mode 100644 index 000000000..ff6e8c85b Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/done-comments-3.png 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 new file mode 100644 index 000000000..cf965d61a Binary files /dev/null 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/error-stuck.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/error-stuck.png new file mode 100644 index 000000000..f8cb693db Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/error-stuck.png differ diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/layout.json b/apps/board/src/__tests__/fixtures/latch-strips-2x/layout.json new file mode 100644 index 000000000..c54009a7f --- /dev/null +++ b/apps/board/src/__tests__/fixtures/latch-strips-2x/layout.json @@ -0,0 +1,30 @@ +{ + "armed": { + "dotsX": 777, + "dotsY": 52 + }, + "queued-next-sweep": { + "dotsX": 759, + "dotsY": 52 + }, + "running": { + "dotsX": 797, + "dotsY": 52 + }, + "done-comments-3": { + "dotsX": 710, + "dotsY": 52 + }, + "done-approved": { + "dotsX": 710, + "dotsY": 52 + }, + "error-retrying-next-sweep": { + "dotsX": 853, + "dotsY": 52 + }, + "error-stuck": { + "dotsX": 735, + "dotsY": 52 + } +} 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 new file mode 100644 index 000000000..c557bb475 Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/queued-next-sweep.png differ diff --git a/apps/board/src/__tests__/fixtures/latch-strips-2x/running.png b/apps/board/src/__tests__/fixtures/latch-strips-2x/running.png new file mode 100644 index 000000000..e2c89b15e Binary files /dev/null and b/apps/board/src/__tests__/fixtures/latch-strips-2x/running.png differ diff --git a/apps/board/src/__tests__/latch-banner-parity.test.ts b/apps/board/src/__tests__/latch-banner-parity.test.ts index 90ee3b848..06adb12c4 100644 --- a/apps/board/src/__tests__/latch-banner-parity.test.ts +++ b/apps/board/src/__tests__/latch-banner-parity.test.ts @@ -5,11 +5,15 @@ import { describe, expect, test } from 'bun:test'; import { GifReader } from 'omggif'; import { PNG } from 'pngjs'; -import { renderBannerGif } from '../latch/banner.ts'; -import type { BannerVariant } from '../latch/variants.ts'; +import { outsideCorners, renderBannerGif } from '../latch/banner.ts'; +import { + PARITY_CASES, + PARITY_SCALE, + parityStrips, + REFERENCE_DIR as REFERENCE, +} from './fixtures/latch-parity.ts'; import { PROTOTYPE_CREATURE } from './fixtures/latch-prototype-creature.ts'; -const REFERENCE = join(import.meta.dir, 'fixtures', 'latch-reference'); const OUT = process.env.LATCH_PARITY_OUT ?? join(tmpdir(), 'latch-parity'); /** A pixel differs when any channel is off by more than this. */ @@ -17,15 +21,9 @@ const CHANNEL_TOLERANCE = 24; /** The share of pixels per frame allowed to differ. */ const MAX_DIFFERING = 0.005; -const CASES: [string, BannerVariant][] = [ - ['armed', { state: 'armed' }], - ['queued', { state: 'queued', wait: 'next-sweep' }], - ['running', { state: 'running' }], - ['done-comments', { state: 'done-comments', count: 3 }], - ['done-approved', { state: 'done-approved' }], - ['error-retrying', { state: 'error-retrying', wait: 'next-sweep' }], - ['error-stuck', { state: 'error-stuck' }], -]; +/** The prototype had square corners; the runtime's rounded ones are not compared. */ +const CORNERS = outsideCorners(PARITY_SCALE); +const NO_CORNERS = new Uint8Array(CORNERS.length); interface Decoded { width: number; @@ -79,11 +77,20 @@ interface Verdict { diff: Uint8Array; } -function compare(ref: Uint8Array, got: Uint8Array, width: number): Verdict { +function compare( + ref: Uint8Array, + got: Uint8Array, + width: number, + skip: Uint8Array +): Verdict { const diff = new Uint8Array(ref.length); const bad = new Uint8Array(ref.length / 4); + let compared = 0; for (let p = 0; p < bad.length; p++) { const i = p * 4; + diff[i + 3] = 255; + if (skip[p]) continue; + compared++; const off = Math.max( Math.abs(ref[i]! - got[i]!), Math.abs(ref[i + 1]! - got[i + 1]!), @@ -93,7 +100,6 @@ function compare(ref: Uint8Array, got: Uint8Array, width: number): Verdict { diff[i] = bad[p] ? 255 : got[i]! >> 2; diff[i + 1] = bad[p] ? 0 : got[i + 1]! >> 2; diff[i + 2] = bad[p] ? 0 : got[i + 2]! >> 2; - diff[i + 3] = 255; } let differing = 0; let blocks = 0; @@ -104,7 +110,7 @@ function compare(ref: Uint8Array, got: Uint8Array, width: number): Verdict { if (x + 1 < width && bad[p + 1] && bad[p + width] && bad[p + width + 1]) blocks++; } - return { share: differing / bad.length, blocks, diff }; + return { share: differing / compared, blocks, diff }; } const passes = (v: Verdict) => v.share <= MAX_DIFFERING && v.blocks === 0; @@ -112,10 +118,11 @@ const passes = (v: Verdict) => v.share <= MAX_DIFFERING && v.blocks === 0; const pct = (share: number) => `${(share * 100).toFixed(3)}%`; describe('latch banner parity with the approved prototype', () => { - for (const [name, variant] of CASES) { + const strips = parityStrips(); + for (const [name, variant] of PARITY_CASES) { test(name, () => { const ref = decode(readFileSync(join(REFERENCE, `latch-${name}.gif`))); - const got = decode(renderBannerGif(PROTOTYPE_CREATURE, variant)); + const got = decode(renderBannerGif(PROTOTYPE_CREATURE, variant, strips)); expect([got.width, got.height]).toEqual([ref.width, ref.height]); expect(got.frames.length).toBe(ref.frames.length); expect(got.delays).toEqual(ref.delays); @@ -123,7 +130,7 @@ describe('latch banner parity with the approved prototype', () => { const failures: string[] = []; const shares: string[] = []; ref.frames.forEach((refFrame, i) => { - const v = compare(refFrame, got.frames[i]!, ref.width); + const v = compare(refFrame, got.frames[i]!, ref.width, CORNERS); shares.push(`${pct(v.share)}/${v.blocks}`); if (passes(v)) return; mkdirSync(OUT, { recursive: true }); @@ -156,7 +163,7 @@ describe('the parity gate rejects a missing feature', () => { for (const [feature, name, a, b] of FEATURE_PAIRS) { test(feature, () => { const ref = decode(readFileSync(join(REFERENCE, `latch-${name}.gif`))); - const v = compare(ref.frames[a]!, ref.frames[b]!, ref.width); + const v = compare(ref.frames[a]!, ref.frames[b]!, ref.width, NO_CORNERS); expect(v.share).toBeLessThanOrEqual(MAX_DIFFERING); expect(passes(v)).toBe(false); }); diff --git a/apps/board/src/__tests__/latch-banner.test.ts b/apps/board/src/__tests__/latch-banner.test.ts index aa8977b94..3e10b6898 100644 --- a/apps/board/src/__tests__/latch-banner.test.ts +++ b/apps/board/src/__tests__/latch-banner.test.ts @@ -11,6 +11,7 @@ import { } from '../latch/banner.ts'; import { allVariantKeys, + BANNER_SCALE, copyFor, DOT_GAP_PX, DOT_PX, @@ -24,6 +25,8 @@ const MR_B = 'https://gitlab.com/acme/web/-/merge_requests/2318'; interface GifFrame { delay: number; + transparentIndex: number | null; + dispose: number; pixels: Uint8Array; } @@ -100,12 +103,19 @@ function parseGif(b: Uint8Array): Gif { } const frames: GifFrame[] = []; let delay = 0; + let transparentIndex: number | null = null; + let dispose = 0; while (p < b.length) { const tag = b[p]!; if (tag === 0x3b) break; if (tag === 0x21) { const label = b[p + 1]!; - if (label === 0xf9) delay = (b[p + 4]! | (b[p + 5]! << 8)) * 10; + if (label === 0xf9) { + const flags = b[p + 3]!; + delay = (b[p + 4]! | (b[p + 5]! << 8)) * 10; + transparentIndex = flags & 1 ? b[p + 6]! : null; + dispose = (flags >> 2) & 7; + } [, p] = readSubBlocks(b, p + 2); } else if (tag === 0x2c) { const w = b[p + 5]! | (b[p + 6]! << 8); @@ -113,7 +123,12 @@ function parseGif(b: Uint8Array): Gif { if (b[p + 9]! & 0x80) throw new Error('local colour tables not expected'); const minSize = b[p + 10]!; const [data, next] = readSubBlocks(b, p + 11); - frames.push({ delay, pixels: lzwDecode(data, minSize, w * h) }); + frames.push({ + delay, + transparentIndex, + dispose, + pixels: lzwDecode(data, minSize, w * h), + }); p = next; } else { throw new Error(`unexpected GIF block 0x${tag.toString(16)} at ${p}`); @@ -154,13 +169,17 @@ function toGrid(rows: readonly string[]): boolean[][] { return rows.map(row => [...row].map(c => c === '#')); } +const S = BANNER_SCALE; +const CELL = 3 * S; + /** Centre of sprite cell (col, row) at rest, for the 11-wide creatures. */ function cellCentre(col: number, row: number): [number, number] { - return [28 + (2 + col) * 6 + 3, 32 + row * 6 + 3]; + return [14 * S + (2 + col) * CELL + 4, 16 * S + row * CELL + 4]; } describe('latchBannerGif', () => { - test('encodes a 1040x128 GIF for every variant key', () => { + test('encodes a 1560x192 GIF for every variant key', () => { + expect([GIF_W, GIF_H]).toEqual([1560, 192]); for (const key of allVariantKeys()) { const b = gifFor(key); expect(String.fromCharCode(...b.slice(0, 6))).toBe('GIF89a'); @@ -179,9 +198,9 @@ describe('latchBannerGif', () => { } }, 60_000); - test('every GIF is under 80 KB', () => { + test('every GIF is under 100 KB', () => { for (const key of allVariantKeys()) { - expect({ key, small: gifFor(key).length < 80 * 1024 }).toEqual({ + expect({ key, small: gifFor(key).length < 100 * 1024 }).toEqual({ key, small: true, }); @@ -225,6 +244,62 @@ describe('latchBannerGif', () => { } }, 60_000); + test('the four corner pixels are transparent in every frame and the centre is not', () => { + const corners: [number, number][] = [ + [0, 0], + [GIF_W - 1, 0], + [0, GIF_H - 1], + [GIF_W - 1, GIF_H - 1], + ]; + for (const key of allVariantKeys()) { + const gif = parseGif(gifFor(key)); + const seen = gif.frames.map(f => { + const at = (x: number, y: number) => f.pixels[y * gif.width + x]; + return { + dispose: f.dispose, + corners: corners.map(([x, y]) => at(x, y) === f.transparentIndex), + centre: at(GIF_W / 2, GIF_H / 2) === f.transparentIndex, + }; + }); + expect({ key, seen }).toEqual({ + key, + seen: gif.frames.map(() => ({ + dispose: 1, + corners: [true, true, true, true], + centre: false, + })), + }); + } + }, 60_000); + + test('the corners are a quarter circle of radius 6 CSS px', () => { + const gif = parseGif(gifFor('armed')); + const r = 6 * S; + const clear = gif.frames[0]!.transparentIndex; + const at = (x: number, y: number) => + gif.frames[0]!.pixels[y * gif.width + x] === clear; + // Pixel centres farther than r from the corner circle's centre. + const want: string[] = []; + const got: string[][] = [[], [], [], []]; + for (let y = 0; y < r; y++) { + for (let x = 0; x < r; x++) { + if ((r - x - 0.5) ** 2 + (r - y - 0.5) ** 2 > r * r) + want.push(`${x},${y}`); + const mx = GIF_W - 1 - x; + const my = GIF_H - 1 - y; + if (at(x, y)) got[0]!.push(`${x},${y}`); + if (at(mx, y)) got[1]!.push(`${x},${y}`); + if (at(x, my)) got[2]!.push(`${x},${y}`); + if (at(mx, my)) got[3]!.push(`${x},${y}`); + } + } + expect(want).toContain('13,0'); + expect(want).not.toContain('14,0'); + expect(got).toEqual([want, want, want, want]); + expect(at(r, 0) || at(0, r) || at(r, r)).toBe(false); + expect(rgbAt(gif, 0, 0, 0)).toBe('#2a2638'); + }); + test('names the variant when its strip is missing', () => { expect(() => renderBannerGif(PROTOTYPE_CREATURE, { state: 'nope' as never }) @@ -237,7 +312,12 @@ describe('latchBannerGif', () => { LAYOUT as Record )['queued-cooldown']!; const dot = (i: number, frame: number) => - rgbAt(gif, frame, dotsX + i * (DOT_PX + DOT_GAP_PX) + 4, dotsY + 4); + rgbAt( + gif, + frame, + dotsX + i * (DOT_PX + DOT_GAP_PX) * S + (DOT_PX * S) / 2, + dotsY + (DOT_PX * S) / 2 + ); expect([0, 1, 2].map(i => dot(i, 0))).toEqual(Array(3).fill('#2a2638')); expect([0, 1, 2].map(i => dot(i, 3))).toEqual(Array(3).fill('#e6b860')); expect(gif.frames.map(f => f.delay)).toEqual(Array(8).fill(420)); @@ -245,8 +325,8 @@ describe('latchBannerGif', () => { test('moves the running ring head clockwise', () => { const gif = parseGif(gifFor('running')); - const topLeft: [number, number] = [948 + 6, 42 + 6]; - const topMid: [number, number] = [964 + 6, 42 + 6]; + const topLeft: [number, number] = [474 * S + 9, 21 * S + 9]; + const topMid: [number, number] = [482 * S + 9, 21 * S + 9]; expect(rgbAt(gif, 0, ...topLeft)).toBe('#7fbfe8'); expect(rgbAt(gif, 0, ...topMid)).toBe('#5d5775'); expect(rgbAt(gif, 1, ...topMid)).toBe('#7fbfe8'); diff --git a/apps/board/src/__tests__/latch-markers.test.ts b/apps/board/src/__tests__/latch-markers.test.ts index b263bb9f3..c28ac0634 100644 --- a/apps/board/src/__tests__/latch-markers.test.ts +++ b/apps/board/src/__tests__/latch-markers.test.ts @@ -42,7 +42,7 @@ describe('bodies', () => { }); describe('latchBody', () => { - const GIF = '![Follow-up review queued](/uploads/a/b.gif){width=520}'; + const GIF = '![Follow-up review queued](/uploads/a/b.gif)'; const MARKERS: Record = { armed: LATCH_MARKER, queued: LATCH_MARKER_QUEUED, @@ -67,20 +67,32 @@ describe('latchBody', () => { }); describe('imageMarkdownFor', () => { - test('titles the upload and sizes it to 520 px', () => { + test('titles the upload and leaves it unsized, so GitLab fills the column', () => { expect( imageMarkdownFor( 'Follow-up review running', '![latch-7-running.gif](/uploads/c0ffee/latch-7-running.gif)' ) - ).toBe( - '![Follow-up review running](/uploads/c0ffee/latch-7-running.gif){width=520}' - ); + ).toBe('![Follow-up review running](/uploads/c0ffee/latch-7-running.gif)'); + }); + + test('drops the width from an older sized banner', () => { + expect( + imageMarkdownFor( + 'Follow-up review running', + '![Follow-up review running](/uploads/c0ffee/latch-7-running.gif){width=520}' + ) + ).toBe('![Follow-up review running](/uploads/c0ffee/latch-7-running.gif)'); }); }); describe('imageMarkdownOf', () => { - test('matches a gif with a width suffix', () => { + test('matches an unsized gif', () => { + const img = '![x](/uploads/a/b.gif)'; + expect(imageMarkdownOf(latchBody('queued', img, 'Queued.'))).toBe(img); + }); + + test('still matches an older gif with a width suffix', () => { const img = '![x](/uploads/a/b.gif){width=520}'; expect(imageMarkdownOf(latchBody('queued', img, 'Queued.'))).toBe(img); }); diff --git a/apps/board/src/__tests__/latch-post.test.ts b/apps/board/src/__tests__/latch-post.test.ts index 85893ab97..71a385462 100644 --- a/apps/board/src/__tests__/latch-post.test.ts +++ b/apps/board/src/__tests__/latch-post.test.ts @@ -2,8 +2,10 @@ import { afterAll, describe, expect, test } from 'bun:test'; import type { LatchRef } from '../latch/discussions.ts'; import { + cachedUpload, putLatchRow, readLatchRow, + rememberUpload, type LatchRow, } from '../latch/latch-state.ts'; import { latchKindOf } from '../latch/markers.ts'; @@ -98,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){width=520}' + '![Ready for another pass?](/uploads/ab12/latch-2317-armed.gif)' ); }); @@ -112,6 +114,23 @@ describe('bannerMarkdown', () => { expect(calls).toEqual(['upload:latch-2317-queued-cooldown.gif']); }); + test('a pre-3x upload cached with a width is uploaded again and replaced', async () => { + const db = tempDb(); + const { gw, calls } = gateway(); + rememberUpload( + MR, + 'running', + '![Follow-up review running](/uploads/old1/latch-2317-running.gif){width=520}', + db + ); + const md = await bannerMarkdown(gw, 42, MR, 2317, { state: 'running' }, db); + const want = + '![Follow-up review running](/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 different variant uploads its own banner', async () => { const db = tempDb(); const { gw, calls } = gateway(); @@ -132,9 +151,7 @@ describe('postLatch', () => { expect(posted).toEqual({ discussionId: 'd1', rootNoteId: 1 }); expect(calls).toEqual(['upload:latch-2317-armed.gif', 'createDiscussion']); expect(latchKindOf(bodies[0]!)).toBe('armed'); - expect(bodies[0]).toContain( - '/uploads/ab12/latch-2317-armed.gif){width=520}' - ); + expect(bodies[0]).toContain('](/uploads/ab12/latch-2317-armed.gif)\n'); expect(bodies[0]).toContain('Resolve this thread'); }); }); diff --git a/apps/board/src/__tests__/latch-variants.test.ts b/apps/board/src/__tests__/latch-variants.test.ts index db853795a..81767473b 100644 --- a/apps/board/src/__tests__/latch-variants.test.ts +++ b/apps/board/src/__tests__/latch-variants.test.ts @@ -93,9 +93,9 @@ describe('variant keys', () => { ); }); - test('dot pitch is shared in 2x px', () => { - expect(DOT_PX).toBe(8); - expect(DOT_GAP_PX).toBe(8); + test('dot pitch is shared in CSS px', () => { + expect(DOT_PX).toBe(4); + expect(DOT_GAP_PX).toBe(4); }); test('unknown keys have no variant', () => { diff --git a/apps/board/src/latch/banner.ts b/apps/board/src/latch/banner.ts index 6616d685d..e019d0e15 100644 --- a/apps/board/src/latch/banner.ts +++ b/apps/board/src/latch/banner.ts @@ -14,6 +14,9 @@ import { PNG } from 'pngjs'; import { LAYOUT, STRIPS } from '../../assets/latch/index.ts'; import { + BANNER_CSS_H, + BANNER_CSS_W, + BANNER_SCALE, copyFor, DOT_GAP_PX, DOT_PX, @@ -21,21 +24,21 @@ import { type BannerVariant, } from './variants.ts'; -export const GIF_W = 1040; -export const GIF_H = 128; +export const GIF_W = BANNER_CSS_W * BANNER_SCALE; +export const GIF_H = BANNER_CSS_H * BANNER_SCALE; -const CELL = 6; -const BOX_X = 28; -const BOX_W = 90; -const BOX_H = 66; -/** The approved prototype centred the 33px CSS box at y 15.5, which Chromium - painted from device row 32, one below the arithmetic centre. */ -const BOX_Y = (GIF_H - BOX_H) / 2 + 1; - -const RING_X = 948; -const RING_Y = (GIF_H - 44) / 2; -const RING_CELL = 12; -const RING_PITCH = 16; +// Geometry in CSS px, multiplied by the strip set's scale when painting. +const CELL = 3; +const BOX_X = 14; +const BOX_W = 45; +/** The approved prototype centred the 33px box at y 15.5, which Chromium + painted from y 16. */ +const BOX_Y = 16; +const RING_X = 474; +const RING_Y = 21; +const RING_CELL = 6; +const RING_PITCH = 8; +const CORNER_RADIUS = 6; /** Clockwise from the top-left, in ring cells. */ const RING: [number, number][] = [ [0, 0], @@ -49,6 +52,7 @@ const RING: [number, number][] = [ ]; const EYE = '#ece8f4'; +const STRIP_BG = '#2a2638'; const RING_UNLIT = '#5d5775'; const SPARKLES: [number, number][] = [ [0, -3], @@ -179,17 +183,21 @@ class PaintedColours { class Canvas { /** The painted colour id per pixel, or -1 where the strip shows through. */ - readonly owner = new Int16Array(GIF_W * GIF_H).fill(-1); + readonly owner: Int16Array; constructor( readonly data: Uint8Array, + readonly width: number, + readonly height: number, private readonly painted: PaintedColours - ) {} + ) { + this.owner = new Int16Array(width * height).fill(-1); + } rect(x0: number, y0: number, w: number, h: number, colour: Rgb, alpha = 1) { - for (let y = Math.max(0, y0); y < Math.min(GIF_H, y0 + h); y++) { - for (let x = Math.max(0, x0); x < Math.min(GIF_W, x0 + w); x++) { - const p = y * GIF_W + x; + for (let y = Math.max(0, y0); y < Math.min(this.height, y0 + h); y++) { + for (let x = Math.max(0, x0); x < Math.min(this.width, x0 + w); x++) { + const p = y * this.width + x; const i = p << 2; const c: Rgb = [0, 1, 2].map(k => Math.round(colour[k]! * alpha + this.data[i + k]! * (1 - alpha)) @@ -204,6 +212,42 @@ class Canvas { } } +/** 1 for each pixel outside the banner's rounded corners, at `scale`. */ +export function outsideCorners(scale: number): Uint8Array { + const w = BANNER_CSS_W * scale; + const h = BANNER_CSS_H * scale; + const r = CORNER_RADIUS * scale; + const out = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + const dy = y < r ? r - y - 0.5 : y >= h - r ? y + 0.5 - (h - r) : 0; + if (dy === 0) continue; + for (let x = 0; x < w; x++) { + const dx = x < r ? r - x - 0.5 : x >= w - r ? x + 0.5 - (w - r) : 0; + if (dx * dx + dy * dy > r * r) out[y * w + x] = 1; + } + } + return out; +} + +export interface StripLayout { + dotsX: number; + dotsY: number; +} + +/** Text strips rendered at `scale` by scripts/build-latch-band.ts, with each + key's dots origin in that scale's px. */ +export interface StripSet { + scale: number; + strips: Record; + layout: Record; +} + +export const BANNER_STRIPS: StripSet = { + scale: BANNER_SCALE, + strips: STRIPS, + layout: LAYOUT, +}; + /** A creature to paint: its two leg poses and its eye cells as [col, row]. */ export interface Creature { frames: [boolean[][], boolean[][]]; @@ -223,37 +267,52 @@ export function latchBannerGif( export function renderBannerGif( creature: Creature, - variant: BannerVariant + variant: BannerVariant, + strips: StripSet = BANNER_STRIPS ): Uint8Array { const key = variantKey(variant); const copy = copyFor(variant); - const stripPath = STRIPS[key]; - const layout = ( - LAYOUT as Record - )[key]; + const stripPath = strips.strips[key]; + const layout = strips.layout[key]; if (!stripPath || !layout) throw new Error(`no latch strip for ${key}`); + const s = strips.scale; + const width = BANNER_CSS_W * s; + const height = BANNER_CSS_H * s; const strip = PNG.sync.read(readFileSync(stripPath)); + if (strip.width !== width || strip.height !== height) { + throw new Error( + `latch strip ${key} is ${strip.width}x${strip.height}, not ${width}x${height}` + ); + } const [grid, shuffled] = creature.frames; const body = hex(copy.accent); const eye = hex(EYE); - const spriteW = grid[0]!.length * CELL; - const restX = BOX_X + Math.floor((BOX_W - spriteW) / CELL / 2) * CELL; - const restY = BOX_Y; + const cell = CELL * s; + const boxX = BOX_X * s; + const boxY = BOX_Y * s; + const boxW = BOX_W * s; + const spriteW = grid[0]!.length * cell; + const restX = boxX + Math.floor((boxW - spriteW) / cell / 2) * cell; const plan = framesFor(variant.state); const painted = new PaintedColours(); const canvases = plan.map(f => { - const canvas = new Canvas(new Uint8Array(strip.data), painted); + const canvas = new Canvas( + new Uint8Array(strip.data), + width, + height, + painted + ); const cells = f.legs ? shuffled : grid; const ox = Math.min( - Math.max(restX + (f.dx ?? 0) * CELL, BOX_X), - BOX_X + BOX_W - spriteW + Math.max(restX + (f.dx ?? 0) * cell, boxX), + boxX + boxW - spriteW ); - const oy = restY - (f.dy ?? 0) * CELL; + const oy = boxY - (f.dy ?? 0) * cell; cells.forEach((row, y) => row.forEach((on, x) => { - if (on) canvas.rect(ox + x * CELL, oy + y * CELL, CELL, CELL, body); + if (on) canvas.rect(ox + x * cell, oy + y * cell, cell, cell, body); }) ); // invadrs' accent cells hold every enclosed hole, mouths included; only @@ -261,40 +320,43 @@ export function renderBannerGif( for (const [x, y] of creature.eyes) { if (cells[y]?.[x]) continue; const shut = f.blink && y < grid.length / 2; - canvas.rect(ox + x * CELL, oy + y * CELL, CELL, CELL, shut ? body : eye); + canvas.rect(ox + x * cell, oy + y * cell, cell, cell, shut ? body : eye); } if (f.spark) { for (const [x, y] of SPARKLES) { - canvas.rect(BOX_X + x * CELL, BOX_Y + y * CELL, CELL, CELL, eye); + canvas.rect(boxX + x * cell, boxY + y * cell, cell, cell, eye); } } if (copy.indicator === 'dots') { + const dot = DOT_PX * s; for (let i = 0; i < (f.dots ?? 0); i++) { - const x = layout.dotsX + i * (DOT_PX + DOT_GAP_PX); - canvas.rect(x, layout.dotsY, DOT_PX, DOT_PX, body); + const x = layout.dotsX + i * (DOT_PX + DOT_GAP_PX) * s; + canvas.rect(x, layout.dotsY, dot, dot, body); } } if (copy.indicator === 'ring') { + const size = RING_CELL * s; RING.forEach(([cx, cy], k) => { const d = ((((f.head ?? 0) - k) % 8) + 8) % 8; - const x = RING_X + cx * RING_PITCH; - const y = RING_Y + cy * RING_PITCH; - if (d === 0) canvas.rect(x, y, RING_CELL, RING_CELL, body); - else if (d === 1) - canvas.rect(x, y, RING_CELL, RING_CELL, body, 0xe0 / 255); - else if (d === 2) - canvas.rect(x, y, RING_CELL, RING_CELL, body, 0xb0 / 255); - else canvas.rect(x, y, RING_CELL, RING_CELL, hex(RING_UNLIT)); + const x = (RING_X + cx * RING_PITCH) * s; + const y = (RING_Y + cy * RING_PITCH) * s; + if (d === 0) canvas.rect(x, y, size, size, body); + else if (d === 1) canvas.rect(x, y, size, size, body, 0xe0 / 255); + else if (d === 2) canvas.rect(x, y, size, size, body, 0xb0 / 255); + else canvas.rect(x, y, size, size, hex(RING_UNLIT)); }); } return canvas; }); - const size = GIF_W * GIF_H * 4; + const size = width * height * 4; const all = new Uint8Array(canvases.length * size); canvases.forEach((c, i) => all.set(c.data, i * size)); - const quantized = quantize(all, 256 - painted.colours.length); + const quantized = quantize(all, 255 - painted.colours.length); const palette = [...quantized, ...painted.colours]; + const clear = palette.length; + const bg = hex(STRIP_BG); + const corners = outsideCorners(s); const gif = GIFEncoder(); canvases.forEach((c, i) => { @@ -303,12 +365,19 @@ export function renderBannerGif( // their own exact entry instead. const index = applyPalette(c.data, palette); for (let p = 0; p < c.owner.length; p++) { - if (c.owner[p]! >= 0) index[p] = quantized.length + c.owner[p]!; + if (corners[p]) index[p] = clear; + else if (c.owner[p]! >= 0) index[p] = quantized.length + c.owner[p]!; } - gif.writeFrame(index, GIF_W, GIF_H, { - palette: i === 0 ? palette : undefined, + // Frames are whole and every one carries the clear corners, so leaving + // the last frame in place never shows a corner. The clear slot holds the + // strip background for viewers that ignore transparency. + gif.writeFrame(index, width, height, { + palette: i === 0 ? [...palette, bg] : undefined, delay: plan[i]!.delay, repeat: 0, + transparent: true, + transparentIndex: clear, + dispose: 1, }); }); gif.finish(); diff --git a/apps/board/src/latch/gifenc.d.ts b/apps/board/src/latch/gifenc.d.ts index 12a47eb5e..561197c87 100644 --- a/apps/board/src/latch/gifenc.d.ts +++ b/apps/board/src/latch/gifenc.d.ts @@ -7,7 +7,14 @@ declare module 'gifenc' { index: Uint8Array, width: number, height: number, - opts?: { palette?: Palette; delay?: number; repeat?: number } + opts?: { + palette?: Palette; + delay?: number; + repeat?: number; + transparent?: boolean; + transparentIndex?: number; + dispose?: number; + } ): void; finish(): void; bytes(): Uint8Array; diff --git a/apps/board/src/latch/markers.ts b/apps/board/src/latch/markers.ts index 34d89f363..c90641e02 100644 --- a/apps/board/src/latch/markers.ts +++ b/apps/board/src/latch/markers.ts @@ -60,19 +60,18 @@ export function spentLatchBody(imageMarkdown: string): string { return latchBody('spent', imageMarkdown, SPENT_COPY); } -/** GitLab stretches an unsized image to the column; 520 CSS px shows the - 1040 px banner at 2x. */ +/** Unsized on purpose: GitLab stretches the image to the comment column. */ export function imageMarkdownFor( title: string, markdownFromUpload: string ): string { const url = markdownFromUpload.match(/\]\(([^)]+)\)/)?.[1] ?? markdownFromUpload; - return `![${title}](${url}){width=520}`; + return `![${title}](${url})`; } -/** The banner's markdown image, pulled back out of a latch body. Matches the - sized GIF form and the older unsized PNG. */ +/** The banner's markdown image, pulled back out of a latch body. Also matches + the older `{width=N}` GIF form. */ export function imageMarkdownOf(body: string): string | null { const m = body.match(/!\[[^\]]*\]\([^)]+\)(?:\{width=\d+\})?/); return m ? m[0] : null; diff --git a/apps/board/src/latch/post.ts b/apps/board/src/latch/post.ts index 6697fcf94..1dfdbf8eb 100644 --- a/apps/board/src/latch/post.ts +++ b/apps/board/src/latch/post.ts @@ -85,7 +85,8 @@ export async function bannerMarkdown( ): Promise { const key = variantKey(variant); const cached = cachedUpload(mrUrl, key, db); - if (cached) return cached; + // A sized row is a pre-3x upload; it is replaced, not reused. + if (cached && !cached.includes('{width=')) return cached; const up = await gw.uploadFile( projectId, `latch-${iid}-${key}.gif`, diff --git a/apps/board/src/latch/variants.ts b/apps/board/src/latch/variants.ts index 5037828fe..d8a4b826e 100644 --- a/apps/board/src/latch/variants.ts +++ b/apps/board/src/latch/variants.ts @@ -64,9 +64,14 @@ const RETRY_WAITS = Object.keys(RETRY_SUB) as WaitReason[]; const MAX_COUNT = 20; -/** The indicator's dots, in 2x px: each dot is DOT_PX square, DOT_GAP_PX apart. */ -export const DOT_PX = 8; -export const DOT_GAP_PX = 8; +/** The banner's size in CSS px. Strips and GIFs are rasterised at BANNER_SCALE. */ +export const BANNER_CSS_W = 520; +export const BANNER_CSS_H = 64; +export const BANNER_SCALE = 3; + +/** The indicator's dots, in CSS px: each dot is DOT_PX square, DOT_GAP_PX apart. */ +export const DOT_PX = 4; +export const DOT_GAP_PX = 4; function cleanCount(count: number | null | undefined): number | null { if (count == null || !Number.isFinite(count)) return null;