diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index 1670eb53518..f16e8179a4b 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -58,8 +58,20 @@ const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ /** Sizes packed into the .ico, which stores each one as an embedded PNG. */ const ICO_SIZES = [16, 32, 48, 64, 128, 256]; -function render(size: number, out: string): void { - const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), source, "-o", out]); +/** + * The menu bar image, which is the same mark with no backdrop and the prompt cut through. + * + * It needs its own source because a status item is a template image: macOS reads the alpha as + * coverage and paints it with the menu bar tint, so the backdrop has to be gone rather than + * recoloured. 44px is 22pt at @2x, which is the menu bar working height and the size this asset + * already shipped at. + */ +const TRAY_OUTPUT = "tray/icon.png"; +const TRAY_SIZE = 44; +const traySource = join(iconsDir, "tray", "icon.svg"); + +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}`); @@ -131,6 +143,10 @@ function generateInto(target: string): { produced: string[]; icnsSkipped: boolea writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); produced.push("icon.ico"); + mkdirSync(join(target, "tray"), { recursive: true }); + render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource); + produced.push(TRAY_OUTPUT); + return { produced, icnsSkipped }; } @@ -139,6 +155,10 @@ function main(): number { console.error(`[icons] missing source: ${source}`); return 1; } + if (!existsSync(traySource)) { + console.error(`[icons] missing source: ${traySource}`); + return 1; + } const check = process.argv.includes("--check"); if (!check) { // Render into scratch first so a failure half way through cannot leave the committed set @@ -174,7 +194,7 @@ function main(): number { } } if (drifted.length > 0) { - console.error(`[icons] these do not match icon.svg: ${drifted.join(", ")}`); + console.error(`[icons] these do not match their source: ${drifted.join(", ")}`); console.error("[icons] regenerate with: bun run icons"); return 1; } diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 2de806b6fe7..b500b7b1192 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index e1d4fc804a0..d7616e4e2c0 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 282567c5627..d378409ff52 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index a6a27245f4c..304e712fd37 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 5ade8afdfae..df082c8d71d 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index c5f321fee56..ac18887b72e 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index d6370872519..27fe4bca5b0 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 6ef2495b7dd..68d2404adda 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 85ab79627a6..904b613b470 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 0276815ce95..f02fc8c8916 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index c6c3c302bb4..5709ec13c39 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index fc2f94a63eb..99ab09e8a15 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index fc65ea2c5de..3320233fbdb 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index 56417eb5243..817af2b2a14 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index def8f032885..d96fa435002 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index bc81c0166ab..4ba7ed664e4 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 7fbc3a19f52..64b6ed4f5b1 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg index 4c1774afa89..7070d673ad5 100644 --- a/desktop/src-tauri/icons/icon.svg +++ b/desktop/src-tauri/icons/icon.svg @@ -1,16 +1,43 @@ - + - + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/icons/tray/icon.png b/desktop/src-tauri/icons/tray/icon.png index f475a9e9c73..1eec4cccaf1 100644 Binary files a/desktop/src-tauri/icons/tray/icon.png and b/desktop/src-tauri/icons/tray/icon.png differ diff --git a/desktop/src-tauri/icons/tray/icon.svg b/desktop/src-tauri/icons/tray/icon.svg new file mode 100644 index 00000000000..1f917f1f018 --- /dev/null +++ b/desktop/src-tauri/icons/tray/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md new file mode 100644 index 00000000000..20a374ad5c8 --- /dev/null +++ b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md @@ -0,0 +1,95 @@ +# Brand icon and menu bar mark + +The shipped app icon is not the product's mark. `desktop/src-tauri/icons/*` derives from an empty +rounded-square ring that came in with the Tauri template, and the vector source written for it in +#5329 reproduced that ring faithfully — the measurement was right and the subject was wrong. + +Two consequences, both visible on a Mac today. The artwork covers 21.4% of the 1024px canvas and +has transparent corners, so macOS 26/27 classifies it as a uniquely shaped icon, strips it onto a +default grey tile and scales it down; Finder shows a grey square with a small black ring in it. +And the menu bar carries the same ring, so nothing on screen says which product this is. + +The real mark already exists in the repository. `assets/logo-light.png` is the mark on +transparency at 512px and `gui/public/favicon.png` is its app-icon composition at 128px: a light +squircle behind a dark six-lobed cloud that holds a `>` and a `_`, flanked by `{` and `}`, inside a +dashed orbit with a dot at top and bottom. Neither has a vector source, and `gui/src/icons.tsx` +holds only 24x24 line icons, so there is nothing to reuse — the vector has to be produced. + +## Where the geometry comes from + +The silhouette is measured, not redrawn. `assets/logo-light.png` is pure black with a shaped alpha +channel, so the outline is the alpha channel: upsample it 4x to 2048px, threshold at alpha 110, +trace with potrace, and map the result back into the 512-unit source space. That yields exactly +nine subpaths — cloud, two braces, four orbit arcs, two dots — and re-rendering them at 512px +disagrees with the thresholded source in **188 of 262144 pixels (0.072%)**, which is antialiasing +rather than a different shape. + +The prompt glyphs cannot be traced. In the source they are engraved: alpha 217+ against a 206 body, +with a lit rim along one edge. Composited at 512px that reads as depth; at 128px and below it reads +as nothing, and an app icon spends most of its life at 32px. Thresholding the emboss produces a +ragged chevron because the lit edge falls below the threshold asymmetrically. + +They are redrawn as flat geometry on the measured centreline instead: + +| glyph | measurement (512 source space) | drawn as | +| --- | --- | --- | +| `>` | rows 214-238 give the upper arm centreline slope 0.5625; rows 254-278 give the lower arm slope -0.5833; the two meet at (214.6, 244); tips at y 196.5 and 292.5 | polyline `193.4 206.5 -> 214.6 244 -> 193.4 281.5`, stroke 22, round cap and join | +| `_` | x 253-324.5, y 271-293.5, ends semicircular | rect 71 x 22.5, rx 11.25 at (253.5, 271) | + +Checked against the source: the chevron's predicted horizontal cross-section is 25.3px against 25px +measured, and the underscore's cap curvature lands within one pixel at both ends. + +## Shape of the change + +The glyphs are a **mask** rather than a lighter fill. Cutting them out of the mark makes the +backdrop show through, which is the flat reading of an engraved groove, and it is also what gives +the menu bar template real holes instead of a black blob. + +The backdrop is a **full-bleed opaque square**, not a pre-rounded tile. Apple's current app icon +guidance asks for a square, unmasked, full-bleed 1024px source and applies the rounded-rectangle +mask and material itself; a baked corner fights that and shows as jagged edges. The 824px inner +tile with a transparent margin is the pre-Tahoe recipe, and the transparent margin is precisely +what triggers today's grey fallback. + +Files: + +- `desktop/src-tauri/icons/icon.svg` — replaced. Full-bleed `#fcfcfc` backdrop, mark in `#2c2c2c`, + glyphs cut by `mask#prompt`, mark placed by `translate(2 26) scale(2)` so the orbit centre sits on + the canvas centre and the ink keeps the 77% coverage the favicon composition uses. +- `desktop/src-tauri/icons/tray/icon.svg` — new. Same curves, no backdrop, black fill, orbit and + dots dropped because at 22pt a dashed circle resolves into grey specks. viewBox is the ink bounds + of what is left plus 6%, so the glyph fills the menu bar height rather than the source margin. +- `desktop/scripts/generate-icons.ts` — `render()` takes a source, and the run emits + `tray/icon.png` at 44px (22pt at @2x) alongside the existing seventeen. Both `icons` and + `icons:check` cover it. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — two additions. The tray raster has to be the + size the generator declares and the generator has to actually render and report it, and the tray + source has to carry the app icon's mask verbatim, wire it onto the mark, and draw distinct curves + that all appear in `icon.svg`. + + Subset alone was too weak, and a review caught it: every interesting way of breaking the tray + removes something, so a strict subset stays a subset. Dropping the mask, deleting the underscore + or repeating a brace in place of the cloud each ship a black blob with green CI. Each of those, + plus removing the generator's tray render and removing its `produced.push`, was applied and run: + all five turn the suite red at 6 pass / 1 fail, and the restored tree is 7 pass / 0 fail. + +Nothing in `desktop/src-tauri/src/tray.rs` changes: it already builds the tray with +`.icon_as_template(true)`, and the asset it includes is the file being replaced. + +## Acceptance + +1. `bun run icons:check` reports every generated artifact matching the source, tray included. +2. `tests/ci-workflows/build-desktop-icon-set.test.ts` passes, and its drift guard fails when the + tray source is perturbed. +3. `icon.png` is fully opaque, and `tray/icon.png` is 44x44 with no non-black opaque pixel. +4. The change lands on `dev`, and a locally built and installed app shows the mark in Finder, the + Dock and the menu bar. + +## Recorded results + +- silhouette trace vs source alpha: 188 / 262144 px (0.072%). +- `icon.png` opaque coverage: 21.4% before, 100.0% after. +- `tray/icon.png`: 44x44, 759 pixels with alpha above zero — 498 fully opaque and 261 antialiased + — and no pixel with alpha whose colour is anything but black, which is what a template image has + to be. Both prompt glyphs are transparent holes rather than white fill. +- `bun run icons` regenerated 18 artifacts; `bun run icons:check` reported 18 matching. diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts index 703356188bb..afb1d83d5ab 100644 --- a/tests/ci-workflows/build-desktop-icon-set.test.ts +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -164,4 +164,72 @@ describe("desktop icon set", () => { .sort(); expect(stray).toEqual([]); }); + + test("the menu bar image is the size the generator declares", () => { + const source = generatorSource(); + const output = /const TRAY_OUTPUT = "([^"]+)";/.exec(source)?.[1]; + const size = Number(/const TRAY_SIZE = (\d+);/.exec(source)?.[1]); + expect(output, "TRAY_OUTPUT is missing from generate-icons.ts").toBeTruthy(); + expect(Number.isFinite(size) && size > 0).toBe(true); + + // Declaring the constants is not the same as rendering them. Without these two the tray could + // be dropped from the run and from `--check` while every assertion below still read the stale + // committed file and passed. + expect(source, "generate-icons.ts declares TRAY_OUTPUT but never renders it") + .toContain("render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource);"); + expect(source, "the tray output is rendered but never reported, so --check skips it") + .toContain("produced.push(TRAY_OUTPUT);"); + + const bytes = readFileSync(join(ICONS_DIR, output!)); + expect(isPng(bytes)).toBe(true); + expect(pngDimensions(bytes)).toEqual({ width: size, height: size }); + }); + + /** + * The menu bar asset has its own SVG because a template image carries no backdrop, not because + * it is a second drawing. Two files holding the same curves is exactly the drift the generator + * was written to remove, so the curves in the smaller one have to be characters out of the + * larger one — a redrawn brace would stop matching here rather than ship as a second mark. + * + * Subset alone is too weak to hold the asset up, because every way of breaking it removes + * something. Dropping the mask, deleting the underscore, or repeating one brace in place of the + * cloud all produce a strict subset, and all three ship a black blob into the menu bar. So the + * glyph geometry is compared whole, the mask has to be wired onto the mark, and the mark paths + * have to be distinct from each other. + */ + test("the menu bar source is a subset of the app icon source", () => { + const icon = readFileSync(join(ICONS_DIR, "icon.svg"), "utf8"); + const tray = readFileSync(join(ICONS_DIR, "tray", "icon.svg"), "utf8"); + + // The glyphs are the whole point of the mark, so they are compared as a block rather than as + // a bag of paths: an identical mask carries the chevron, the underscore and the white ground + // that turns them into holes. + const iconMask = block(icon, '"); + const trayMask = block(tray, '"); + expect(trayMask, "the tray mask is not the app icon's mask").toBe(iconMask); + expect(trayMask).toContain('stroke-width="22"'); + expect(trayMask).toContain(""); + const trayMark = block(tray, '"); + for (const [name, mark] of [["icon.svg", iconMark], ["tray/icon.svg", trayMark]] as const) { + expect(mark, `${name} draws the mark without the prompt cut`).toContain('mask="url(#prompt)"'); + } + + const trayPaths = [...trayMark.matchAll(/ d="([^"]+)"/g)].map(m => m[1]!); + const iconPaths = [...iconMark.matchAll(/ d="([^"]+)"/g)].map(m => m[1]!); + expect(new Set(trayPaths).size, "tray/icon.svg repeats a curve instead of drawing the mark") + .toBe(trayPaths.length); + expect(trayPaths.length, "the menu bar mark is the cloud and both braces").toBeGreaterThanOrEqual(3); + expect(iconPaths.length).toBeGreaterThan(trayPaths.length); + const foreign = trayPaths.filter(d => !iconPaths.includes(d)); + expect(foreign, "tray/icon.svg draws curves icon.svg does not have").toEqual([]); + + // A status item is tinted from its alpha, so anything but black-and-clear is a mistake, and a + // backdrop would paint the whole menu bar slot. + expect(tray).not.toContain('id="backdrop"'); + const fills = new Set([...tray.matchAll(/fill="([^"]+)"/g)].map(m => m[1]!)); + expect([...fills].sort()).toEqual(["#000000", "#ffffff", "none"]); + }); });