diff --git a/assets/pr-screenshots/favicon-light-tab.png b/assets/pr-screenshots/favicon-light-tab.png new file mode 100644 index 00000000000..803ab3b32ad Binary files /dev/null and b/assets/pr-screenshots/favicon-light-tab.png differ diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index 04ab6e7c803..3acfdc8bb92 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -17,7 +17,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { deflateSync, inflateSync } from "node:zlib"; +import { buildIco, render as renderSvg } from "../../scripts/lib/icon-render"; const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); const iconsDir = join(desktopDir, "src-tauri", "icons"); @@ -71,155 +71,9 @@ const TRAY_OUTPUT = "tray/icon.png"; const TRAY_SIZE = 44; const traySource = join(iconsDir, "tray", "icon.svg"); +/** Render at `size` from `from`, defaulting to the app icon vector. */ function render(size: number, out: string, from: string = source): void { - const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), from, "-o", out]); - if (result.status !== 0) { - const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; - throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); - } - writeFileSync(out, toRgba(readFileSync(out))); -} - -const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - -const CRC_TABLE = (() => { - const table = new Int32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - table[n] = c; - } - return table; -})(); - -function crc32(bytes: Buffer): number { - let c = -1; - for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); - return (c ^ -1) >>> 0; -} - -function chunk(type: string, data: Buffer): Buffer { - const head = Buffer.alloc(8); - head.writeUInt32BE(data.length, 0); - head.write(type, 4, "latin1"); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); - return Buffer.concat([head, data, crc]); -} - -/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ -function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { - for (let i = 0; i < row.length; i += 1) { - const a = i >= stride ? row[i - stride]! : 0; - const b = prior[i]!; - const c = i >= stride ? prior[i - stride]! : 0; - let add = 0; - if (kind === 1) add = a; - else if (kind === 2) add = b; - else if (kind === 3) add = (a + b) >> 1; - else if (kind === 4) { - const p = a + b - c; - const pa = Math.abs(p - a); - const pb = Math.abs(p - b); - const pc = Math.abs(p - c); - add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; - } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); - row[i] = (row[i]! + add) & 0xff; - } -} - -/** - * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has - * alpha untouched. - * - * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render - * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's - * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at - * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the - * committed bytes depend on which librsvg drew them. - */ -function toRgba(bytes: Buffer): Buffer { - if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); - - let header: { width: number; height: number } | null = null; - let colourType = -1; - const idat: Buffer[] = []; - for (let at = 8; at + 8 <= bytes.length; ) { - const length = bytes.readUInt32BE(at); - const type = bytes.subarray(at + 4, at + 8).toString("latin1"); - const data = bytes.subarray(at + 8, at + 8 + length); - if (type === "IHDR") { - colourType = data[9]!; - if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); - header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; - } else if (type === "IDAT") idat.push(Buffer.from(data)); - else if (type === "IEND") break; - at += 12 + length; - } - if (colourType === 6) return bytes; - if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); - - const { width, height } = header; - const raw = inflateSync(Buffer.concat(idat)); - const stride = width * 3; - const out = Buffer.alloc(height * (1 + width * 4)); - let prior = Buffer.alloc(stride); - for (let y = 0; y < height; y += 1) { - const start = y * (1 + stride); - const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); - unfilter(raw[start]!, row, prior, 3); - const target = y * (1 + width * 4); - out[target] = 0; - for (let x = 0; x < width; x += 1) { - row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); - out[target + 1 + x * 4 + 3] = 0xff; - } - prior = row; - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - return Buffer.concat([ - PNG_SIGNATURE, - chunk("IHDR", ihdr), - chunk("IDAT", deflateSync(out, { level: 9 })), - chunk("IEND", Buffer.alloc(0)), - ]); -} - -/** - * Pack PNGs into an ICO. - * - * Written here rather than shelled out because the alternative is ImageMagick, and adding a - * system dependency to regenerate an icon is a worse trade than 30 lines of a container format - * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO - * spells "256". - */ -function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { - const header = Buffer.alloc(6); - header.writeUInt16LE(0, 0); // reserved - header.writeUInt16LE(1, 2); // type: icon - header.writeUInt16LE(entries.length, 4); - - const directory = Buffer.alloc(16 * entries.length); - let offset = header.length + directory.length; - entries.forEach((entry, index) => { - const at = index * 16; - directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); - directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); - directory.writeUInt8(0, at + 2); // palette colours - directory.writeUInt8(0, at + 3); // reserved - directory.writeUInt16LE(1, at + 4); // colour planes - directory.writeUInt16LE(32, at + 6); // bits per pixel - directory.writeUInt32LE(entry.bytes.length, at + 8); - directory.writeUInt32LE(offset, at + 12); - offset += entry.bytes.length; - }); - - return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); + renderSvg(size, out, from); } /** diff --git a/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md new file mode 100644 index 00000000000..6e09e5c5973 --- /dev/null +++ b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md @@ -0,0 +1,54 @@ +# The dashboard and documentation favicons + +The desktop app now renders its icon and its menu bar image from one traced vector. The two +favicons the product serves are still hand-made rasters with no source, and one of them is broken +in a way the file itself does not show. + +`docs-site/public/favicon.png` is the dark variant of the mark: white on transparency, 192px, +36225 of 36864 pixels carrying some alpha but every one of them RGB `(255,255,255)`, exactly one +fully opaque pixel, corner `(255,255,255,3)`. Composited on white it is a white square. A browser +tab strip is light by default and Starlight names the favicon unconditionally, so the documentation +site effectively has no favicon in light mode. `favicon.ico` beside it carries the same artwork at +16, 32 and 48, also with no fully opaque pixel. + +`gui/public/favicon.png` is the light composition and looks right at 128px, but it is a bitmap no +source can regenerate, and it is the shaded artwork rather than the flat mark: its engraved prompt +all but disappears at 16 and 32. Rendering the vector at 128 differs from it in 68% of pixels. That +is a visible simplification, not only a deduplication, and it is the same trade the app icon made. + +This unit covers the favicons and nothing else. Starlight's `logo-light.png` and `logo-dark.png` +stay independent 512px rasters — they are the brand artwork the vector was traced from, not +derived assets. `og.png` also stays, and carries a separate pre-existing defect worth its own +scope: `docs-site/astro.config.mjs` declares it 1200x630 while the committed file is 1536x1024. + +## Shape of the change + +- `scripts/lib/icon-render.ts` — new. The renderer, the RGBA re-encode and the ICO packer move here + out of `desktop/scripts/generate-icons.ts`, which keeps its size tables and imports them. Two + generators sharing one renderer is the point; a second copy of the PNG re-encode would be a + second place for the alpha bug to come back. +- `scripts/brand-favicons.ts` — new. Renders `desktop/src-tauri/icons/icon.svg` into + `gui/public/favicon.png` (128), `docs-site/public/favicon.png` (192) and + `docs-site/public/favicon.ico` (16, 32, 48) — the names, sizes and formats the two sites already + reference, so no page or config changes. `--check` regenerates into scratch and compares bytes, + the same contract the desktop set has. +- `package.json` — `favicons` and `favicons:check`. +- `tests/ci-workflows/brand-favicons.test.ts` — new, registered in `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json`. Asserts the declared sizes match what the two + sites ask for, that each committed favicon is that size with its alpha channel intact, that the + ICO carries exactly the declared sizes as embedded PNGs, and that both favicons read on a light + tab. + + That last one is why the test decodes pixels. An opaque corner alone is not enough: a plain white + square has an opaque corner and is still invisible. So it requires an opaque light corner **and** + at least 10% of the image to be opaque pixels whose luminance differs from that corner by more + than 64 — the mark actually being there. + +## Acceptance + +1. `bun run favicons:check` reports every favicon matching the source. +2. The new test fails on both ways of being invisible, checked by applying each: the + white-on-transparent artwork this replaces gives 3 pass / 1 fail, and a solid `#fcfcfc` square + gives 3 pass / 1 fail. The generated favicons give 4 pass / 0 fail. +3. `bun run privacy:scan`, `bun run structure:check` and the two test-layout guards stay green. + diff --git a/docs-site/public/favicon.ico b/docs-site/public/favicon.ico index de1689b20be..8f120715e2d 100644 Binary files a/docs-site/public/favicon.ico and b/docs-site/public/favicon.ico differ diff --git a/docs-site/public/favicon.png b/docs-site/public/favicon.png index 7a741511cff..6c26bfafb00 100644 Binary files a/docs-site/public/favicon.png and b/docs-site/public/favicon.png differ diff --git a/gui/public/favicon.png b/gui/public/favicon.png index 3a50bfa241d..8827b1fdc27 100644 Binary files a/gui/public/favicon.png and b/gui/public/favicon.png differ diff --git a/package.json b/package.json index b8671c59b0e..cfabb4378af 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "typecheck": "bun x tsc --noEmit", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", + "favicons": "bun scripts/brand-favicons.ts", + "favicons:check": "bun scripts/brand-favicons.ts --check", "skill:surface": "bun scripts/generate-ocx-skill-surface.ts", "skill:surface:check": "bun scripts/generate-ocx-skill-surface.ts --check", "structure:index": "bun scripts/structure-ssot.ts --fix", diff --git a/scripts/brand-favicons.ts b/scripts/brand-favicons.ts new file mode 100644 index 00000000000..043cbb3222e --- /dev/null +++ b/scripts/brand-favicons.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +/** + * Render the favicons the dashboard and the documentation site serve from the app icon vector. + * + * They used to be hand-made bitmaps with no source, and one of them was wrong in a way the file + * itself does not show: docs-site/public/favicon.png was the dark variant of the mark, white on + * transparency, 98.3% covered. A browser tab strip is light by default, so the documentation + * site's favicon was white on white. The .ico beside it carried the same artwork at 16, 32 and 48. + * + * Sizes and names here are the ones gui/index.html and docs-site/astro.config.mjs already + * reference, so nothing on either site has to change to pick these up. + * + * --check regenerates into a temporary directory and compares byte for byte, the same contract + * desktop/scripts/generate-icons.ts has for the app icon set. + */ +import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildIco, render } from "./lib/icon-render"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const source = join(repoRoot, "desktop", "src-tauri", "icons", "icon.svg"); + +/** Favicon PNGs the two sites reference, by path relative to the repository root. */ +const FAVICON_SIZES: Record = { + "gui/public/favicon.png": 128, + "docs-site/public/favicon.png": 192, +}; + +/** The documentation site's .ico, which Starlight names directly, and the sizes it carries. */ +const ICO_OUTPUT = "docs-site/public/favicon.ico"; +const ICO_SIZES = [16, 32, 48]; + +/** Scratch files are flat, so a nested output name becomes one path segment. */ +function scratchName(output: string): string { + return output.replaceAll("/", "__"); +} + +function generateInto(target: string): string[] { + const produced: string[] = []; + for (const [output, size] of Object.entries(FAVICON_SIZES)) { + render(size, join(target, scratchName(output)), source); + produced.push(output); + } + + const parts: Array<{ size: number; bytes: Buffer }> = []; + for (const size of ICO_SIZES) { + const scratch = join(target, `.ico-${size}.png`); + render(size, scratch, source); + parts.push({ size, bytes: readFileSync(scratch) }); + rmSync(scratch, { force: true }); + } + writeFileSync(join(target, scratchName(ICO_OUTPUT)), buildIco(parts)); + produced.push(ICO_OUTPUT); + + return produced; +} + +function main(): number { + if (!existsSync(source)) { + console.error(`[favicons] missing source: ${source}`); + return 1; + } + const check = process.argv.includes("--check"); + const scratch = mkdtempSync(join(tmpdir(), "ocx-favicons-")); + try { + const produced = generateInto(scratch); + if (!check) { + // Written in one pass at the end, so a renderer failure part way through cannot leave one + // site on the new mark and the other on the old one. + for (const output of produced) { + writeFileSync(join(repoRoot, output), readFileSync(join(scratch, scratchName(output)))); + } + console.log(`[favicons] regenerated ${produced.length} favicons from ${basename(source)}`); + return 0; + } + + const drifted = produced.filter(output => { + const committed = join(repoRoot, output); + if (!existsSync(committed)) return true; + return !readFileSync(join(scratch, scratchName(output))).equals(readFileSync(committed)); + }); + if (drifted.length > 0) { + console.error(`[favicons] these do not match the app icon vector: ${drifted.join(", ")}`); + console.error("[favicons] regenerate with: bun run favicons"); + return 1; + } + console.log(`[favicons] ${produced.length} favicons match the source`); + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +process.exit(main()); + diff --git a/scripts/lib/icon-render.ts b/scripts/lib/icon-render.ts new file mode 100644 index 00000000000..89ab2783add --- /dev/null +++ b/scripts/lib/icon-render.ts @@ -0,0 +1,162 @@ +/** + * Rendering and packing shared by the icon generators. + * + * Two generators read the same vector: desktop/scripts/generate-icons.ts builds the app icon set + * and the menu bar image, and scripts/brand-favicons.ts builds the favicons the dashboard and the + * documentation site serve. They shared nothing at first, and the PNG re-encode below is exactly + * the kind of detail that would have been copied and then fixed in one place only. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { deflateSync, inflateSync } from "node:zlib"; + +export function render(size: number, out: string, from: string): void { + const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), from, "-o", out]); + if (result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; + throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); + } + writeFileSync(out, toRgba(readFileSync(out))); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(bytes: Buffer): number { + let c = -1; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ -1) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, "latin1"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ +function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { + for (let i = 0; i < row.length; i += 1) { + const a = i >= stride ? row[i - stride]! : 0; + const b = prior[i]!; + const c = i >= stride ? prior[i - stride]! : 0; + let add = 0; + if (kind === 1) add = a; + else if (kind === 2) add = b; + else if (kind === 3) add = (a + b) >> 1; + else if (kind === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); + row[i] = (row[i]! + add) & 0xff; + } +} + +/** + * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has + * alpha untouched. + * + * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render + * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's + * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at + * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the + * committed bytes depend on which librsvg drew them. + */ +export function toRgba(bytes: Buffer): Buffer { + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); + + let header: { width: number; height: number } | null = null; + let colourType = -1; + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + const data = bytes.subarray(at + 8, at + 8 + length); + if (type === "IHDR") { + colourType = data[9]!; + if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); + header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; + } else if (type === "IDAT") idat.push(Buffer.from(data)); + else if (type === "IEND") break; + at += 12 + length; + } + if (colourType === 6) return bytes; + if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); + + const { width, height } = header; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 3; + const out = Buffer.alloc(height * (1 + width * 4)); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + unfilter(raw[start]!, row, prior, 3); + const target = y * (1 + width * 4); + out[target] = 0; + for (let x = 0; x < width; x += 1) { + row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); + out[target + 1 + x * 4 + 3] = 0xff; + } + prior = row; + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(out, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +/** + * Pack PNGs into an ICO. + * + * Written here rather than shelled out because the alternative is ImageMagick, and adding a + * system dependency to regenerate an icon is a worse trade than 30 lines of a container format + * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO + * spells "256". + */ +export function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(entries.length, 4); + + const directory = Buffer.alloc(16 * entries.length); + let offset = header.length + directory.length; + entries.forEach((entry, index) => { + const at = index * 16; + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); + directory.writeUInt8(0, at + 2); // palette colours + directory.writeUInt8(0, at + 3); // reserved + directory.writeUInt16LE(1, at + 4); // colour planes + directory.writeUInt16LE(32, at + 6); // bits per pixel + directory.writeUInt32LE(entry.bytes.length, at + 8); + directory.writeUInt32LE(offset, at + 12); + offset += entry.bytes.length; + }); + + return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1e5d3154b1c..b0a1b3b81ea 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -303,6 +303,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "brand-favicons.test.ts": "ci-workflows", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", diff --git a/tests/ci-workflows/brand-favicons.test.ts b/tests/ci-workflows/brand-favicons.test.ts new file mode 100644 index 00000000000..b782975da96 --- /dev/null +++ b/tests/ci-workflows/brand-favicons.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { inflateSync } from "node:zlib"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The favicons the dashboard and the documentation site serve are generated from the app icon + * vector by scripts/brand-favicons.ts. That script's --check compares byte for byte and needs + * rsvg-convert, which CI does not have, so what is asserted here is the shape of the committed + * set: the sizes the sites reference, the alpha channel, and the one property the old artwork + * failed. + * + * That property is worth naming. The documentation favicon used to be the dark variant of the + * mark — white on transparency — so on a light browser tab strip it was white on white and the + * site effectively had no favicon. Nothing about the file looks wrong until it is composited. + */ +const GENERATOR = repoPath("scripts/brand-favicons.ts"); + +function generatorSource(): string { + return readFileSync(GENERATOR, "utf8"); +} + +function block(source: string, opening: string, closing: string): string { + const start = source.indexOf(opening); + expect(start, `${opening} is missing from brand-favicons.ts`).toBeGreaterThan(-1); + const end = source.indexOf(closing, start + opening.length); + expect(end, `${opening} is not terminated in brand-favicons.ts`).toBeGreaterThan(-1); + return source.slice(start + opening.length, end); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Truecolour with alpha. */ +const RGBA = 6; + +function ihdr(bytes: Buffer): { width: number; height: number; colourType: number } { + expect(bytes.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + expect(bytes.subarray(12, 16).toString("latin1")).toBe("IHDR"); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), colourType: bytes[25]! }; +} + +/** Decode an 8-bit RGBA PNG into one flat pixel buffer. */ +function decode(bytes: Buffer): { width: number; height: number; pixels: Buffer } { + const { width, height, colourType } = ihdr(bytes); + expect(colourType).toBe(RGBA); + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + if (type === "IDAT") idat.push(Buffer.from(bytes.subarray(at + 8, at + 8 + length))); + if (type === "IEND") break; + at += 12 + length; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const pixels = Buffer.alloc(height * stride); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const filter = raw[start]!; + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + for (let i = 0; i < stride; i += 1) { + const a = i >= 4 ? row[i - 4]! : 0; + const b = prior[i]!; + const c = i >= 4 ? prior[i - 4]! : 0; + let add = 0; + if (filter === 1) add = a; + else if (filter === 2) add = b; + else if (filter === 3) add = (a + b) >> 1; + else if (filter === 4) { + const guess = a + b - c; + const pa = Math.abs(guess - a); + const pb = Math.abs(guess - b); + const pc = Math.abs(guess - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + row[i] = (row[i]! + add) & 0xff; + } + row.copy(pixels, y * stride); + prior = row; + } + return { width, height, pixels }; +} + +/** Rec. 709 luminance of the pixel starting at ''at''. */ +function luminance(pixels: Buffer, at: number): number { + return 0.2126 * pixels[at]! + 0.7152 * pixels[at + 1]! + 0.0722 * pixels[at + 2]!; +} + +describe("brand favicons", () => { + const source = generatorSource(); + const pngs = new Map( + [...block(source, "const FAVICON_SIZES: Record = {", "};") + .matchAll(/"([^"]+)":\s*(\d+)/g)].map(m => [m[1]!, Number(m[2])]), + ); + const icoOutput = /const ICO_OUTPUT = "([^"]+)";/.exec(source)?.[1]; + const icoSizes = block(source, "const ICO_SIZES = [", "]") + .split(",") + .map(part => Number(part.trim())) + .filter(n => Number.isFinite(n)); + + test("the generator still declares the favicons the sites reference", () => { + expect(pngs.size).toBeGreaterThan(1); + expect(icoOutput).toBeTruthy(); + expect(icoSizes.length).toBeGreaterThan(2); + + // The sizes are not free choices: these are the numbers the two sites already ask for. + expect(readFileSync(repoPath("gui/index.html"), "utf8")).toContain('href="/favicon.png"'); + const astro = readFileSync(repoPath("docs-site/astro.config.mjs"), "utf8"); + expect(astro).toContain('favicon: "/favicon.ico"'); + expect(astro).toContain('sizes: "192x192"'); + expect(pngs.get("docs-site/public/favicon.png")).toBe(192); + }); + + test("every declared favicon is committed at its declared size with alpha intact", () => { + const wrong: string[] = []; + for (const [output, size] of pngs) { + const { width, height, colourType } = ihdr(readFileSync(repoPath(output))); + if (width !== size || height !== size) wrong.push(`${output}: ${width}x${height} != ${size}`); + if (colourType !== RGBA) wrong.push(`${output}: colour type ${colourType}`); + } + expect(wrong).toEqual([]); + }); + + test("the documentation .ico carries exactly the declared sizes as PNGs", () => { + const ico = readFileSync(repoPath(icoOutput!)); + expect(ico.readUInt16LE(0)).toBe(0); + expect(ico.readUInt16LE(2)).toBe(1); + expect(ico.readUInt16LE(4)).toBe(icoSizes.length); + const seen: number[] = []; + for (let i = 0; i < icoSizes.length; i += 1) { + const entry = 6 + i * 16; + const length = ico.readUInt32LE(entry + 8); + const offset = ico.readUInt32LE(entry + 12); + expect(offset + length).toBeLessThanOrEqual(ico.length); + const { width, height } = ihdr(ico.subarray(offset, offset + length)); + expect(width).toBe(height); + expect(ico[entry]).toBe(width % 256); + seen.push(width); + } + expect(seen).toEqual(icoSizes); + }); + + /** + * The regression the generator exists for. Both favicons are now drawn on the opaque backdrop + * the app icon uses, so they composite against a light tab strip instead of disappearing into + * it. The documentation artwork this replaces was white on transparency with a corner alpha of + * 3 and no fully opaque pixel anywhere. + */ + test("both favicons show on a light tab instead of vanishing into it", () => { + const failures: string[] = []; + for (const output of pngs.keys()) { + const { width, height, pixels } = decode(readFileSync(repoPath(output))); + + // An opaque light corner is the backdrop rather than transparency. + const corner = luminance(pixels, 0); + if (pixels[3] !== 255) failures.push(output + ": corner alpha " + pixels[3]); + if (corner < 200) failures.push(output + ": corner luminance " + corner.toFixed(0)); + + // A backdrop alone is not a favicon. An opaque white square passes the check above and is + // still invisible, so the mark has to actually be there: count the pixels that survive + // compositing and differ from the backdrop enough to read at tab size. + let ink = 0; + for (let at = 0; at < pixels.length; at += 4) { + if (pixels[at + 3]! < 128) continue; + if (Math.abs(luminance(pixels, at) - corner) > 64) ink += 1; + } + const coverage = ink / (width * height); + if (coverage < 0.1) failures.push(output + ": only " + (coverage * 100).toFixed(1) + "% ink"); + } + expect(failures, "these favicons vanish on a light tab strip").toEqual([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d0ff792be6d..d5413609fb8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -134,6 +134,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "brand-favicons.test.ts": "ci-workflows", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows",