Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/pr-screenshots/favicon-light-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 3 additions & 149 deletions desktop/scripts/generate-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

Binary file modified docs-site/public/favicon.ico
Binary file not shown.
Binary file modified docs-site/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified gui/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions scripts/brand-favicons.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
"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());

Loading
Loading