diff --git a/e2e/sharing.spec.mjs b/e2e/sharing.spec.mjs
index 9c0c27f..88a12aa 100644
--- a/e2e/sharing.spec.mjs
+++ b/e2e/sharing.spec.mjs
@@ -108,7 +108,11 @@ test('two peers exchange keys, share a sealed diff, and import it', async () =>
await mgr.locator('input.rename').fill('Bob — laptop')
await mgr.locator('input.rename').press('Enter')
await expect(mgr.getByText('Bob — laptop')).toBeVisible()
+ // Removal is guarded by a confirmation dialog.
await mgr.getByTitle('Remove').click()
+ const confirm = pageA.getByRole('dialog', { name: 'Remove trusted key?' })
+ await expect(confirm).toBeVisible()
+ await confirm.getByRole('button', { name: 'Remove' }).click()
await expect(mgr.getByText(/No trusted keys yet/i)).toBeVisible()
} finally {
await appA.close()
diff --git a/e2e/trusted-keys.spec.mjs b/e2e/trusted-keys.spec.mjs
index 0355ee9..1f6a95d 100644
--- a/e2e/trusted-keys.spec.mjs
+++ b/e2e/trusted-keys.spec.mjs
@@ -51,11 +51,18 @@ test('the trusted-keys manager stays stable with 15 keys', async () => {
await expect(mgr.getByText('Teammate 08 — laptop')).toBeVisible()
await expect(mgr.locator('li.key')).toHaveCount(N)
- // Remove two different rows: the count drops, the removed ones vanish, the
- // rest are untouched.
+ // Remove two different rows (each behind a confirmation): the count drops,
+ // the removed ones vanish, the rest are untouched.
+ const confirmRemove = () =>
+ page
+ .getByRole('dialog', { name: 'Remove trusted key?' })
+ .getByRole('button', { name: 'Remove' })
+ .click()
await mgr.locator('li.key', { hasText: 'Teammate 03' }).getByTitle('Remove').click()
+ await confirmRemove()
await expect(mgr.locator('li.key')).toHaveCount(N - 1)
await mgr.locator('li.key', { hasText: 'Teammate 12' }).getByTitle('Remove').click()
+ await confirmRemove()
await expect(mgr.locator('li.key')).toHaveCount(N - 2)
await expect(mgr.getByText('Teammate 03', { exact: true })).toBeHidden()
await expect(mgr.getByText('Teammate 12', { exact: true })).toBeHidden()
diff --git a/electron-builder.yml b/electron-builder.yml
index 3b78ba4..a9d96cd 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -108,3 +108,15 @@ dmg:
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
+ # Assisted-installer branding. Set explicitly rather than relying on
+ # buildResources auto-detection, for the same reason dmg.background is (a
+ # silent fallback to NSIS's stock art ships a wrong-looking installer). Both
+ # are 24-bit BMPs rendered from the SVGs in resources/ — regenerate with
+ # `npm run render:brand` (scripts/render-brand-assets.mjs), never hand-edit
+ # the .bmp (it also re-renders icon.png from logo.svg). installerHeader
+ # (150x57) rides the inner-page header; installerSidebar (164x314) is the
+ # welcome/finish panel. The installer/uninstaller and header icons default to
+ # the app icon (icon.png), which is correct — no need to restate them.
+ installerHeader: resources/installerHeader.bmp
+ installerSidebar: resources/installerSidebar.bmp
+ uninstallerSidebar: resources/installerSidebar.bmp
diff --git a/package.json b/package.json
index e7714a0..2e20deb 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"build:linux": "npm run build && electron-builder --linux --publish never",
"docker:up": "docker compose up --build",
"docker:down": "docker compose down",
- "render:dmg-bg": "electron scripts/render-dmg-background.mjs"
+ "render:dmg-bg": "electron scripts/render-dmg-background.mjs",
+ "render:brand": "electron scripts/render-brand-assets.mjs"
},
"dependencies": {
"chardet": "^2.0.0",
diff --git a/resources/background.png b/resources/background.png
index 85d0b7b..3de37b2 100644
Binary files a/resources/background.png and b/resources/background.png differ
diff --git a/resources/background@2x.png b/resources/background@2x.png
index 8ef050e..43f9204 100644
Binary files a/resources/background@2x.png and b/resources/background@2x.png differ
diff --git a/resources/dmg-background.svg b/resources/dmg-background.svg
index 35e5365..c1d8c43 100644
--- a/resources/dmg-background.svg
+++ b/resources/dmg-background.svg
@@ -3,17 +3,14 @@
1. LIGHT, not dark. The DMG window renders in the light appearance even
when the user's system is in dark mode, so Finder draws the two icon
captions ("Diff Bro" and "Applications") in black. A dark backdrop
- makes them unreadable.
+ makes them unreadable. The brand's warmth therefore comes from the
+ app icon's own dark tile and the amber drag-arrow — the field itself
+ stays a light warm paper so the captions read.
2. The canvas is 1600x1000 but the LAYOUT lives in the top-left 640x400,
which is the window size set in electron-builder.yml. Finder draws this
- image at natural size anchored top-left and does not scale it, so a
- user resizing the window past 640x400 would otherwise expose the bare
- white window background. The extra canvas is filled pure white and the
- artwork fades to pure white before the 640x400 edge, so the seam
- between image and window background is invisible at any window size.
- (A Finder window cannot be made non-resizable — there is no such
- property in the .DS_Store or the AppleScript Finder dictionary — so
- making the resize harmless is the available fix.)
+ image at natural size anchored top-left and does not scale it, so the
+ artwork fades to pure white before the 640x400 edge — a user resizing
+ the window past 640x400 only ever exposes more white, never a seam.
Never edit background.png / background@2x.png directly: run
`npm run render:dmg-bg` (scripts/render-dmg-background.mjs), which
@@ -22,58 +19,56 @@
below (centres 180,208 and 460,208). -->
diff --git a/resources/icon.png b/resources/icon.png
index 7dd9fb8..b445c03 100644
Binary files a/resources/icon.png and b/resources/icon.png differ
diff --git a/resources/installer-header.svg b/resources/installer-header.svg
new file mode 100644
index 0000000..162b125
--- /dev/null
+++ b/resources/installer-header.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/resources/installer-sidebar.svg b/resources/installer-sidebar.svg
new file mode 100644
index 0000000..b2f97e5
--- /dev/null
+++ b/resources/installer-sidebar.svg
@@ -0,0 +1,42 @@
+
+
diff --git a/resources/installerHeader.bmp b/resources/installerHeader.bmp
new file mode 100644
index 0000000..71df818
Binary files /dev/null and b/resources/installerHeader.bmp differ
diff --git a/resources/installerSidebar.bmp b/resources/installerSidebar.bmp
new file mode 100644
index 0000000..2aa767f
Binary files /dev/null and b/resources/installerSidebar.bmp differ
diff --git a/resources/logo.svg b/resources/logo.svg
index fa73328..767aa3b 100644
--- a/resources/logo.svg
+++ b/resources/logo.svg
@@ -1,43 +1,29 @@
diff --git a/scripts/check-theme-depth.mjs b/scripts/check-theme-depth.mjs
index 85c9763..6d10d23 100644
--- a/scripts/check-theme-depth.mjs
+++ b/scripts/check-theme-depth.mjs
@@ -56,7 +56,22 @@ const tokenBlocks = blocks(tokensCss)
const themeBlocks = blocks(themesCss)
const structuralDefaults = tokenBlocks[':root'] || {}
-const THEMES = ['light', 'dark', 'solar', 'neon', 'nord', 'sepia', 'nyan', 'matrix', 'contrast']
+const THEMES = [
+ 'light',
+ 'dark',
+ 'solar',
+ 'neon',
+ 'nord',
+ 'sepia',
+ 'dim',
+ 'beacon',
+ 'meridian',
+ 'linen',
+ 'bloom',
+ 'nyan',
+ 'matrix',
+ 'contrast'
+]
// Effective token map for a theme: structural defaults (tokens.css) < the light
// palette on the bare :root < the theme's own overrides. Light additionally
diff --git a/scripts/render-brand-assets.mjs b/scripts/render-brand-assets.mjs
new file mode 100644
index 0000000..ef302ca
--- /dev/null
+++ b/scripts/render-brand-assets.mjs
@@ -0,0 +1,113 @@
+// Rasterize the SVG brand sources in resources/ to the binaries the packagers
+// consume:
+//
+// npm run render:brand
+//
+// logo.svg -> icon.png (1024, macOS/Win/Linux app icon)
+// installer-sidebar.svg -> installerSidebar.bmp (164x314, NSIS welcome panel)
+// installer-header.svg -> installerHeader.bmp (150x57, NSIS inner header)
+//
+// Run this after editing any of those SVGs. The binaries are what actually
+// ship, so leaving them stale silently reverts the artwork (the same trap the
+// DMG backdrop documents). The DMG backdrop has its own renderer,
+// scripts/render-dmg-background.mjs.
+//
+// Rendering goes through the Electron already in devDependencies — no added
+// rasterizer (CLAUDE.md rule 2), same engine that draws the UI. BMP is encoded
+// inline (24-bit, bottom-up) because NSIS needs BMP and there is no dependency
+// that would produce one. If this exits with "Cannot read properties of
+// undefined (reading 'whenReady')", ELECTRON_RUN_AS_NODE=1 is set — run
+// `env -u ELECTRON_RUN_AS_NODE npm run render:brand`.
+import { readFileSync, writeFileSync, rmSync } from 'fs'
+import { tmpdir } from 'os'
+import { createRequire } from 'module'
+import { fileURLToPath, pathToFileURL } from 'url'
+import { dirname, join } from 'path'
+
+const { app, BrowserWindow } = createRequire(import.meta.url)('electron')
+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
+const res = (f) => join(root, 'resources', f)
+
+const JOBS = [
+ { src: 'logo.svg', w: 1024, h: 1024, out: 'icon.png', format: 'png' },
+ { src: 'installer-sidebar.svg', w: 164, h: 314, out: 'installerSidebar.bmp', format: 'bmp' },
+ { src: 'installer-header.svg', w: 150, h: 57, out: 'installerHeader.bmp', format: 'bmp' }
+]
+
+// A 24-bit BGR, bottom-up BMP from Electron's top-down BGRA bitmap buffer.
+function encodeBmp(bgra, w, h) {
+ const rowSize = (w * 3 + 3) & ~3
+ const pad = rowSize - w * 3
+ const pixels = rowSize * h
+ const buf = Buffer.alloc(54 + pixels)
+ buf.write('BM', 0)
+ buf.writeUInt32LE(54 + pixels, 2)
+ buf.writeUInt32LE(54, 10)
+ buf.writeUInt32LE(40, 14)
+ buf.writeInt32LE(w, 18)
+ buf.writeInt32LE(h, 22)
+ buf.writeUInt16LE(1, 26)
+ buf.writeUInt16LE(24, 28)
+ buf.writeUInt32LE(pixels, 34)
+ buf.writeInt32LE(2835, 38)
+ buf.writeInt32LE(2835, 42)
+ let off = 54
+ for (let sy = h - 1; sy >= 0; sy--) {
+ for (let x = 0; x < w; x++) {
+ const si = (sy * w + x) * 4
+ buf[off++] = bgra[si]
+ buf[off++] = bgra[si + 1]
+ buf[off++] = bgra[si + 2]
+ }
+ for (let p = 0; p < pad; p++) buf[off++] = 0
+ }
+ return buf
+}
+
+async function render(win, { src, w, h, out, format }) {
+ // The source SVG carries its own intrinsic width/height; override them so it
+ // scales to the target (icon.png is 1024 from a 256 master). Loaded from a
+ // file:// URL — a data: URL is treated as an image document Chromium aborts
+ // before capturePage can read it, and a comment in the XML prolog fails the
+ // same loader, so strip comments too.
+ const svg = readFileSync(res(src), 'utf8')
+ .replace(//g, '')
+ .replace(/(