diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3c31f35..a26dbb5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -113,6 +113,15 @@ jobs:
with:
node-version: "26"
cache: npm
+ # The mac icon is the Icon Composer document (build/icon.icon);
+ # compiling it needs actool ≥ 26 (Xcode 26+). macos-15 images carry
+ # several Xcodes with an older default — select the latest stable
+ # explicitly. No-op for the windows/linux matrix legs.
+ - name: Select Xcode 26+ for actool (mac icon compile)
+ if: runner.os == 'macOS'
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
- run: npm ci
- name: Native renderer tests on this OS
run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
diff --git a/build/icon-flat.svg b/build/icon-flat.svg
new file mode 100644
index 0000000..19fa818
--- /dev/null
+++ b/build/icon-flat.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/build/icon.ico b/build/icon.ico
new file mode 100644
index 0000000..7b0f230
Binary files /dev/null and b/build/icon.ico differ
diff --git a/build/icon.icon/Assets/lh_0 2.svg b/build/icon.icon/Assets/lh_0 2.svg
new file mode 100644
index 0000000..6ca3e24
--- /dev/null
+++ b/build/icon.icon/Assets/lh_0 2.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/build/icon.icon/Assets/lh_1 2.svg b/build/icon.icon/Assets/lh_1 2.svg
new file mode 100644
index 0000000..0f795aa
--- /dev/null
+++ b/build/icon.icon/Assets/lh_1 2.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/build/icon.icon/Assets/lh_2 2.svg b/build/icon.icon/Assets/lh_2 2.svg
new file mode 100644
index 0000000..bbcb692
--- /dev/null
+++ b/build/icon.icon/Assets/lh_2 2.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/build/icon.icon/icon.json b/build/icon.icon/icon.json
new file mode 100644
index 0000000..dcdac9d
--- /dev/null
+++ b/build/icon.icon/icon.json
@@ -0,0 +1,37 @@
+{
+ "fill" : {
+ "automatic-gradient" : "srgb:0.07843,0.07843,0.07843,1.00000"
+ },
+ "groups" : [
+ {
+ "layers" : [
+ {
+ "image-name" : "lh_2 2.svg",
+ "name" : "lh_2 2"
+ },
+ {
+ "image-name" : "lh_1 2.svg",
+ "name" : "lh_1 2"
+ },
+ {
+ "image-name" : "lh_0 2.svg",
+ "name" : "lh_0 2"
+ }
+ ],
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.3
+ }
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+}
\ No newline at end of file
diff --git a/build/icon.png b/build/icon.png
new file mode 100644
index 0000000..315d202
Binary files /dev/null and b/build/icon.png differ
diff --git a/docs/desktop-release.md b/docs/desktop-release.md
index 3ac8301..e6dd66f 100644
--- a/docs/desktop-release.md
+++ b/docs/desktop-release.md
@@ -12,7 +12,7 @@ Windows signing is deliberately disabled for this first release. SmartScreen may
The custom app bar stays on all three platforms. macOS keeps its left-side traffic lights; Windows/Linux use Electron's native right-side Window Controls Overlay, with CSS space reserved from the overlay's actual geometry. Windows/Linux hide the native menu until Alt is pressed. macOS keeps its system menu.
-The existing `build/icon.icns` is the source for all platforms: electron-builder converts it to ICO/Linux PNGs. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues).
+Icons are per-platform (2026-10-13): macOS compiles the Icon Composer document `build/icon.icon` via actool into `Assets.car` (macOS 26 Liquid Glass) plus a fallback icns that `scripts/after-pack.cjs` replaces with the hand-packed full-res `build/icon.icns` (actool's caps at 256px); Windows embeds `build/icon.ico` and Linux uses `build/icon.png`, both generated from the flat design (`build/icon-flat.svg`) by `node scripts/make-icons.mjs`. Building the mac icon needs Xcode 26+ (`actool`) — `desktop:dist` injects `DEVELOPER_DIR` locally and the release workflow selects Xcode via setup-xcode. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues).
## Build pipeline
diff --git a/electron-builder.yml b/electron-builder.yml
index ba15b18..5c91e18 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -13,6 +13,19 @@ appId: com.learnharness.LearnHarness
productName: LearnHarness
directories:
output: dist
+# Icons (owner ruling, 2026-10-13) — per-platform sources in build/:
+# mac build/icon.icon — the Icon Composer document (layered Liquid
+# Glass); actool (Xcode 26+) compiles Assets.car (CFBundleIconName,
+# macOS 26+ renders the true layered icon) + an actool fallback icns
+# capped at 256px, which scripts/after-pack.cjs swaps for the
+# hand-packed full-res build/icon.icns (pre-Tahoe keeps 16→1024).
+# Requires actool ≥ 26: desktop:dist injects DEVELOPER_DIR locally;
+# the release workflow's mac job runs setup-xcode.
+# win build/icon.ico — generated (16→256) from the flat design by
+# scripts/make-icons.mjs.
+# linux build/icon.png — 512×512, same script. Rounded squircle with
+# baked transparency on both (owner ruling) — Windows/Linux don't
+# mask, the shape ships in the artwork.
files:
# Only the shell goes into the asar. ORDER IS LOAD-BEARING (payload
# wave, 2026-09-24): electron-builder's dependency collector packs the
@@ -82,6 +95,7 @@ extraResources:
filter:
- "standalone/**/*"
mac:
+ icon: build/icon.icon
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
# for humans; ZIP + blockmap are the updater's payload.
@@ -114,7 +128,9 @@ mac:
notarize: true
dmg:
title: "${productName} ${version}"
+afterPack: scripts/after-pack.cjs
win:
+ icon: build/icon.ico
target:
- target: nsis
arch:
@@ -128,6 +144,7 @@ nsis:
oneClick: true
perMachine: false
linux:
+ icon: build/icon.png
target:
- target: AppImage
arch:
diff --git a/package.json b/package.json
index dcbb9fe..c4c1e69 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"typecheck": "tsc --noEmit",
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
"dev:fresh": "bash scripts/dev-fresh.sh",
- "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
+ "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
},
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs
new file mode 100644
index 0000000..69597b9
--- /dev/null
+++ b/scripts/after-pack.cjs
@@ -0,0 +1,27 @@
+// electron-builder afterPack hook — mac only.
+//
+// WHY (owner ruling 2026-10-13): mac.icon is the Icon Composer document
+// (build/icon.icon); actool compiles it into Assets.car (the macOS 26
+// Liquid Glass render) PLUS a backwards-compat icns — but actool's
+// fallback icns caps at 256px (known actool behavior), which looks soft
+// at Finder/Launchpad sizes on pre-Tahoe macOS. This hook swaps in the
+// hand-packed full-res build/icon.icns (all 10 entries, 16→1024, packed
+// via iconutil from the design's PNG exports) AFTER packaging and BEFORE
+// signing, so:
+//
+// macOS 26+ → Assets.car (CFBundleIconName) — true layered glass icon;
+// older mac → icon.icns (CFBundleIconFile) — today's full-res flatten.
+//
+// Windows/Linux builds return immediately (their icons are static files:
+// build/icon.ico / build/icon.png).
+const fs = require("node:fs");
+const path = require("node:path");
+
+module.exports = async function afterPack(context) {
+ if (context.electronPlatformName !== "darwin") return;
+ const appBundle = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`);
+ const target = path.join(appBundle, "Contents", "Resources", "icon.icns");
+ const source = path.join(context.packager.info.projectDir, "build", "icon.icns");
+ fs.copyFileSync(source, target);
+ console.log(`[after-pack] pre-Tahoe fallback icns (full-res) → ${target}`);
+};
diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs
new file mode 100644
index 0000000..066dbb6
--- /dev/null
+++ b/scripts/make-icons.mjs
@@ -0,0 +1,55 @@
+// Regenerate the Windows/Linux icon set from the flat design source
+// (build/icon-flat.svg — the owner's Affinity export, rounded squircle
+// with baked transparency, owner ruling 2026-10-13).
+//
+// build/icon.ico — Windows (.ico with 16/24/32/48/64/128/256 PNG entries)
+// build/icon.png — Linux (512×512; AppImage/deb/desktop entry)
+//
+// macOS does NOT come from here: build/icon.icon (the Icon Composer
+// document) is the mac source of truth, compiled by actool at package
+// time into Assets.car (macOS 26 Liquid Glass) + a fallback icns; the
+// hand-packed build/icon.icns replaces that fallback's 256px cap via
+// scripts/after-pack.cjs (pre-Tahoe keeps full-res).
+//
+// Renders each size DIRECTLY from the SVG (resvg) — sharper squircle
+// edges than downsampling a raster. Committed outputs; rerun only when
+// the design changes: `node scripts/make-icons.mjs`.
+import { Resvg } from "@resvg/resvg-js";
+import { readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const svg = readFileSync(path.join(root, "build", "icon-flat.svg"), "utf8");
+
+const render = (size) => new Resvg(svg, { fitTo: { mode: "width", value: size } }).render(size, size).asPng();
+
+// ---- build/icon.ico: ICONDIR + PNG entries ------------------------------
+const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256];
+const pngs = ICO_SIZES.map(render);
+// Header (6) + one 16-byte directory entry per image, then the PNGs.
+const header = Buffer.alloc(6 + ICO_SIZES.length * 16);
+header.writeUInt16LE(0, 0); // reserved
+header.writeUInt16LE(1, 2); // type: icon
+header.writeUInt16LE(ICO_SIZES.length, 4);
+let offset = header.length;
+ICO_SIZES.forEach((size, i) => {
+ const e = 6 + i * 16;
+ header.writeUInt8(size === 256 ? 0 : size, e); // 0 encodes 256
+ header.writeUInt8(size === 256 ? 0 : size, e + 1);
+ header.writeUInt8(0, e + 2); // colors
+ header.writeUInt8(0, e + 3); // reserved
+ header.writeUInt16LE(1, e + 4); // planes
+ header.writeUInt16LE(32, e + 6); // bpp
+ header.writeUInt32LE(pngs[i].length, e + 8);
+ header.writeUInt32LE(offset, e + 12);
+ offset += pngs[i].length;
+});
+const ico = Buffer.concat([header, ...pngs]);
+writeFileSync(path.join(root, "build", "icon.ico"), ico);
+console.log(`build/icon.ico ${ICO_SIZES.join("/")} — ${(ico.length / 1024).toFixed(1)} KB`);
+
+// ---- build/icon.png: the Linux icon -------------------------------------
+const png512 = render(512);
+writeFileSync(path.join(root, "build", "icon.png"), png512);
+console.log(`build/icon.png 512×512 — ${(png512.length / 1024).toFixed(1)} KB`);