|
1 | 1 | #!/usr/bin/env node |
2 | 2 | /** |
3 | | - * Generate SnCode app icons for all platforms. |
4 | | - * Creates build/icon.png (1024x1024), build/icon.ico, build/icon.icns |
| 3 | + * Generate SnCode app icons + Windows installer assets. |
5 | 4 | * |
6 | | - * Design: Dark rounded-rectangle with a stylized "Sn" monogram + terminal cursor. |
7 | | - * Matches the app's dark neutral palette (#141414 base). |
| 5 | + * Output (all in build/): |
| 6 | + * icon.png 1024x1024 All platforms |
| 7 | + * icon-512.png 512x512 Linux |
| 8 | + * icon.ico multi-res Windows |
| 9 | + * installerSidebar.bmp 164x314 NSIS sidebar |
| 10 | + * installerHeader.bmp 150x57 NSIS header |
| 11 | + * |
| 12 | + * Design: Inset dark squircle, "Sn" monogram with depth. Monochromatic. |
8 | 13 | */ |
9 | 14 |
|
10 | 15 | import sharp from "sharp"; |
11 | 16 | import pngToIco from "png-to-ico"; |
12 | | -import { writeFileSync, mkdirSync } from "fs"; |
| 17 | +import { writeFileSync, mkdirSync, unlinkSync } from "fs"; |
13 | 18 | import { join, dirname } from "path"; |
14 | 19 | import { fileURLToPath } from "url"; |
15 | 20 |
|
16 | 21 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
17 | 22 | const buildDir = join(__dirname, "..", "build"); |
18 | 23 | mkdirSync(buildDir, { recursive: true }); |
19 | 24 |
|
| 25 | +/* ─────────────────────────── SVGs ─────────────────────────── */ |
| 26 | + |
20 | 27 | const SIZE = 1024; |
21 | 28 |
|
22 | | -// SVG icon: dark rounded square with "Sn" text and a blinking cursor accent |
23 | | -const svg = ` |
| 29 | +const logoSvg = ` |
24 | 30 | <svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}"> |
25 | 31 | <defs> |
26 | | - <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> |
27 | | - <stop offset="0%" stop-color="#1e1e1e"/> |
28 | | - <stop offset="100%" stop-color="#111111"/> |
29 | | - </linearGradient> |
30 | | - <linearGradient id="accent" x1="0" y1="0" x2="0" y2="1"> |
31 | | - <stop offset="0%" stop-color="#e0e0e0"/> |
32 | | - <stop offset="100%" stop-color="#a0a0a0"/> |
| 32 | + <linearGradient id="bg" x1="0" y1="0" x2="0.8" y2="1"> |
| 33 | + <stop offset="0%" stop-color="#1c1c20"/> |
| 34 | + <stop offset="100%" stop-color="#0e0e11"/> |
33 | 35 | </linearGradient> |
34 | | - <filter id="shadow" x="-10%" y="-10%" width="120%" height="120%"> |
35 | | - <feDropShadow dx="0" dy="4" stdDeviation="12" flood-color="#000" flood-opacity="0.5"/> |
| 36 | +
|
| 37 | + <!-- Border stroke glow at corners --> |
| 38 | + <radialGradient id="borderTR" cx="0.88" cy="0.12" r="0.4"> |
| 39 | + <stop offset="0%" stop-color="#ffffff" stop-opacity="1"/> |
| 40 | + <stop offset="30%" stop-color="#ffffff" stop-opacity="0.15"/> |
| 41 | + <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/> |
| 42 | + </radialGradient> |
| 43 | + <radialGradient id="borderBL" cx="0.12" cy="0.88" r="0.4"> |
| 44 | + <stop offset="0%" stop-color="#ffffff" stop-opacity="1"/> |
| 45 | + <stop offset="30%" stop-color="#ffffff" stop-opacity="0.15"/> |
| 46 | + <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/> |
| 47 | + </radialGradient> |
| 48 | +
|
| 49 | + <!-- Edge vignette for inset depth --> |
| 50 | + <radialGradient id="vignette" cx="0.5" cy="0.48" r="0.52"> |
| 51 | + <stop offset="0%" stop-color="#000000" stop-opacity="0"/> |
| 52 | + <stop offset="75%" stop-color="#000000" stop-opacity="0"/> |
| 53 | + <stop offset="100%" stop-color="#000000" stop-opacity="0.3"/> |
| 54 | + </radialGradient> |
| 55 | +
|
| 56 | + <!-- Subtle top-center light for dimension --> |
| 57 | + <radialGradient id="topLight" cx="0.5" cy="0.25" r="0.45"> |
| 58 | + <stop offset="0%" stop-color="#ffffff" stop-opacity="0.03"/> |
| 59 | + <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/> |
| 60 | + </radialGradient> |
| 61 | +
|
| 62 | + <!-- Text depth: shadow beneath letters --> |
| 63 | + <filter id="td" x="-5%" y="-5%" width="110%" height="120%"> |
| 64 | + <feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#000" flood-opacity="0.7"/> |
36 | 65 | </filter> |
37 | 66 | </defs> |
38 | 67 |
|
39 | | - <!-- Background rounded square --> |
40 | | - <rect x="64" y="64" width="896" height="896" rx="180" ry="180" |
41 | | - fill="url(#bg)" filter="url(#shadow)" stroke="#2a2a2a" stroke-width="3"/> |
42 | | -
|
43 | | - <!-- Subtle inner border glow --> |
44 | | - <rect x="80" y="80" width="864" height="864" rx="168" ry="168" |
45 | | - fill="none" stroke="#ffffff08" stroke-width="2"/> |
46 | | -
|
47 | | - <!-- Terminal angle bracket ">" on the left --> |
48 | | - <polyline points="220,340 360,512 220,684" |
49 | | - fill="none" stroke="#555555" stroke-width="48" |
50 | | - stroke-linecap="round" stroke-linejoin="round"/> |
51 | | -
|
52 | | - <!-- "Sn" text - the brand --> |
53 | | - <text x="520" y="590" font-family="'SF Pro Display', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" |
54 | | - font-size="320" font-weight="700" letter-spacing="-8"> |
55 | | - <tspan fill="#ffffff">S</tspan><tspan fill="#555555">n</tspan> |
56 | | - </text> |
| 68 | + <!-- Background squircle (inset — no outer shadow) --> |
| 69 | + <rect x="64" y="64" width="896" height="896" rx="212" ry="212" fill="url(#bg)"/> |
| 70 | +
|
| 71 | + <!-- Vignette darkening at edges --> |
| 72 | + <rect x="64" y="64" width="896" height="896" rx="212" ry="212" fill="url(#vignette)"/> |
| 73 | +
|
| 74 | + <!-- Subtle top light --> |
| 75 | + <rect x="64" y="64" width="896" height="896" rx="212" ry="212" fill="url(#topLight)"/> |
| 76 | +
|
| 77 | + <!-- Border: top-right corner glow --> |
| 78 | + <rect x="62" y="62" width="900" height="900" rx="214" ry="214" |
| 79 | + fill="none" stroke="url(#borderTR)" stroke-width="8"/> |
| 80 | + <!-- Border: bottom-left corner glow --> |
| 81 | + <rect x="62" y="62" width="900" height="900" rx="214" ry="214" |
| 82 | + fill="none" stroke="url(#borderBL)" stroke-width="8"/> |
| 83 | +
|
| 84 | + <!-- "S" — bold, white, with depth --> |
| 85 | + <text x="240" y="695" |
| 86 | + font-family="Bahnschrift,'SF Pro Display','Segoe UI',sans-serif" |
| 87 | + font-size="540" font-weight="700" fill="#ffffff" |
| 88 | + filter="url(#td)" letter-spacing="-10">S</text> |
| 89 | +
|
| 90 | + <!-- "n" — visible gray, with depth --> |
| 91 | + <text x="565" y="695" |
| 92 | + font-family="Bahnschrift,'SF Pro Display','Segoe UI',sans-serif" |
| 93 | + font-size="380" font-weight="600" fill="#b4b4bc" |
| 94 | + filter="url(#td)" letter-spacing="-4">n</text> |
| 95 | +</svg>`; |
| 96 | + |
| 97 | +// NSIS sidebar: 164x314 — centered branding |
| 98 | +const sidebarSvg = ` |
| 99 | +<svg xmlns="http://www.w3.org/2000/svg" width="164" height="314" viewBox="0 0 164 314"> |
| 100 | + <defs> |
| 101 | + <linearGradient id="sbg" x1="0" y1="0" x2="0" y2="1"> |
| 102 | + <stop offset="0%" stop-color="#1a1a1e"/> |
| 103 | + <stop offset="100%" stop-color="#0e0e11"/> |
| 104 | + </linearGradient> |
| 105 | + </defs> |
| 106 | + <rect width="164" height="314" fill="url(#sbg)"/> |
| 107 | +
|
| 108 | + <!-- Right-edge separator --> |
| 109 | + <rect x="162" y="0" width="2" height="314" fill="rgba(255,255,255,0.08)"/> |
| 110 | +
|
| 111 | + <!-- Large "Sn" mark, centered --> |
| 112 | + <text x="82" y="125" text-anchor="middle" |
| 113 | + font-family="Bahnschrift,'Segoe UI',sans-serif" |
| 114 | + font-size="80" font-weight="700" fill="#ffffff">S<tspan font-size="55" font-weight="600" fill="#b4b4bc">n</tspan></text> |
| 115 | +
|
| 116 | + <!-- Separator --> |
| 117 | + <rect x="56" y="142" width="52" height="1" rx="0.5" fill="rgba(255,255,255,0.1)"/> |
| 118 | +
|
| 119 | + <!-- App name, centered --> |
| 120 | + <text x="82" y="170" text-anchor="middle" |
| 121 | + font-family="Bahnschrift,'Segoe UI',sans-serif" |
| 122 | + font-size="18" font-weight="600" fill="#d4d4d8" letter-spacing="2">SnCode</text> |
| 123 | +
|
| 124 | + <!-- Tagline, centered --> |
| 125 | + <text x="82" y="192" text-anchor="middle" |
| 126 | + font-family="Bahnschrift,'Segoe UI',sans-serif" |
| 127 | + font-size="11" fill="#71717a" letter-spacing="0.5">AI Coding Agent</text> |
| 128 | +
|
| 129 | + <!-- Version, bottom center --> |
| 130 | + <text x="82" y="296" text-anchor="middle" |
| 131 | + font-family="Bahnschrift,'Segoe UI',sans-serif" |
| 132 | + font-size="10" fill="#52525b">v0.2.0</text> |
| 133 | +</svg>`; |
| 134 | + |
| 135 | +// NSIS header: 150x57 — clean centered bar |
| 136 | +const headerSvg = ` |
| 137 | +<svg xmlns="http://www.w3.org/2000/svg" width="150" height="57" viewBox="0 0 150 57"> |
| 138 | + <defs> |
| 139 | + <linearGradient id="hbg" x1="0" y1="0" x2="1" y2="0"> |
| 140 | + <stop offset="0%" stop-color="#1a1a1e"/> |
| 141 | + <stop offset="100%" stop-color="#131316"/> |
| 142 | + </linearGradient> |
| 143 | + </defs> |
| 144 | + <rect width="150" height="57" fill="url(#hbg)"/> |
| 145 | +
|
| 146 | + <!-- Bottom separator --> |
| 147 | + <rect x="0" y="55" width="150" height="2" fill="rgba(255,255,255,0.08)"/> |
| 148 | +
|
| 149 | + <!-- "SnCode" centered --> |
| 150 | + <text x="75" y="36" text-anchor="middle" |
| 151 | + font-family="Bahnschrift,'Segoe UI',sans-serif" |
| 152 | + font-size="22" font-weight="600" fill="#e4e4e7" letter-spacing="1.5">SnCode</text> |
| 153 | +</svg>`; |
| 154 | + |
| 155 | +/* ─────────── BMP conversion (24-bit, no alpha) ─────────── */ |
| 156 | + |
| 157 | +function rawToBmp24(rgba, w, h, bgR = 0x0e, bgG = 0x0e, bgB = 0x11) { |
| 158 | + const rowBytes = w * 3; |
| 159 | + const pad = (4 - (rowBytes % 4)) % 4; |
| 160 | + const stride = rowBytes + pad; |
| 161 | + const dataSize = stride * h; |
| 162 | + const buf = Buffer.alloc(14 + 40 + dataSize); |
| 163 | + |
| 164 | + buf.write("BM", 0); |
| 165 | + buf.writeUInt32LE(buf.length, 2); |
| 166 | + buf.writeUInt32LE(0, 6); |
| 167 | + buf.writeUInt32LE(54, 10); |
| 168 | + |
| 169 | + buf.writeUInt32LE(40, 14); |
| 170 | + buf.writeInt32LE(w, 18); |
| 171 | + buf.writeInt32LE(h, 22); |
| 172 | + buf.writeUInt16LE(1, 26); |
| 173 | + buf.writeUInt16LE(24, 28); |
| 174 | + buf.writeUInt32LE(0, 30); |
| 175 | + buf.writeUInt32LE(dataSize, 34); |
| 176 | + buf.writeInt32LE(2835, 38); |
| 177 | + buf.writeInt32LE(2835, 42); |
| 178 | + |
| 179 | + for (let y = 0; y < h; y++) { |
| 180 | + const srcY = (h - 1 - y) * w * 4; |
| 181 | + const dstY = 54 + y * stride; |
| 182 | + for (let x = 0; x < w; x++) { |
| 183 | + const si = srcY + x * 4; |
| 184 | + const di = dstY + x * 3; |
| 185 | + const a = rgba[si + 3] / 255; |
| 186 | + buf[di + 0] = Math.round(rgba[si + 2] * a + bgB * (1 - a)); |
| 187 | + buf[di + 1] = Math.round(rgba[si + 1] * a + bgG * (1 - a)); |
| 188 | + buf[di + 2] = Math.round(rgba[si + 0] * a + bgR * (1 - a)); |
| 189 | + } |
| 190 | + } |
| 191 | + return buf; |
| 192 | +} |
57 | 193 |
|
58 | | - <!-- Cursor line (blinking accent) --> |
59 | | - <rect x="808" y="380" width="6" height="260" rx="3" |
60 | | - fill="#666666"/> |
61 | | -</svg> |
62 | | -`; |
| 194 | +/* ──────────────────── Generation ──────────────────── */ |
63 | 195 |
|
64 | 196 | async function generate() { |
65 | | - console.log("Generating SnCode icons..."); |
| 197 | + console.log("Generating SnCode icons + installer assets…\n"); |
66 | 198 |
|
67 | | - // Generate 1024x1024 PNG |
68 | | - const png1024 = await sharp(Buffer.from(svg)) |
69 | | - .resize(1024, 1024) |
70 | | - .png() |
71 | | - .toBuffer(); |
| 199 | + const png1024 = await sharp(Buffer.from(logoSvg)) |
| 200 | + .resize(1024, 1024).png().toBuffer(); |
72 | 201 | writeFileSync(join(buildDir, "icon.png"), png1024); |
73 | | - console.log(" -> build/icon.png (1024x1024)"); |
| 202 | + console.log(" icon.png 1024x1024"); |
74 | 203 |
|
75 | | - // Generate 512x512 PNG (for Linux) |
76 | | - const png512 = await sharp(Buffer.from(svg)) |
77 | | - .resize(512, 512) |
78 | | - .png() |
79 | | - .toBuffer(); |
| 204 | + const png512 = await sharp(Buffer.from(logoSvg)) |
| 205 | + .resize(512, 512).png().toBuffer(); |
80 | 206 | writeFileSync(join(buildDir, "icon-512.png"), png512); |
81 | | - console.log(" -> build/icon-512.png (512x512)"); |
82 | | - |
83 | | - // Generate multiple sizes for ICO |
84 | | - const sizes = [16, 24, 32, 48, 64, 128, 256]; |
85 | | - const pngBuffers = await Promise.all( |
86 | | - sizes.map((s) => |
87 | | - sharp(Buffer.from(svg)).resize(s, s).png().toBuffer() |
88 | | - ) |
89 | | - ); |
90 | | - |
91 | | - // Write temp PNGs for ico generation, then create ICO |
92 | | - const tempPngPaths = []; |
93 | | - for (let i = 0; i < sizes.length; i++) { |
94 | | - const p = join(buildDir, `_temp_${sizes[i]}.png`); |
95 | | - writeFileSync(p, pngBuffers[i]); |
96 | | - tempPngPaths.push(p); |
| 207 | + console.log(" icon-512.png 512x512"); |
| 208 | + |
| 209 | + const icoSizes = [16, 24, 32, 48, 64, 128, 256]; |
| 210 | + const tempPaths = []; |
| 211 | + for (const s of icoSizes) { |
| 212 | + const p = join(buildDir, `_tmp_${s}.png`); |
| 213 | + const b = await sharp(Buffer.from(logoSvg)).resize(s, s).png().toBuffer(); |
| 214 | + writeFileSync(p, b); |
| 215 | + tempPaths.push(p); |
97 | 216 | } |
98 | | - |
99 | | - const icoBuffer = await pngToIco(tempPngPaths); |
100 | | - writeFileSync(join(buildDir, "icon.ico"), icoBuffer); |
101 | | - console.log(" -> build/icon.ico (multi-size)"); |
102 | | - |
103 | | - // Clean up temp files |
104 | | - const { unlinkSync } = await import("fs"); |
105 | | - for (const p of tempPngPaths) { |
106 | | - try { unlinkSync(p); } catch { /* ignore */ } |
107 | | - } |
108 | | - |
109 | | - // Note: .icns generation requires platform-specific tools. |
110 | | - // electron-builder can generate .icns from .png on macOS during packaging. |
111 | | - // For CI, having icon.png is sufficient — electron-builder handles conversion. |
112 | | - console.log(" -> build/icon.icns: skipped (electron-builder auto-generates from icon.png on macOS)"); |
113 | | - |
114 | | - console.log("\nDone! Icons are in the build/ directory."); |
| 217 | + writeFileSync(join(buildDir, "icon.ico"), await pngToIco(tempPaths)); |
| 218 | + for (const p of tempPaths) { try { unlinkSync(p); } catch { /* ignore */ } } |
| 219 | + console.log(" icon.ico multi-res"); |
| 220 | + console.log(" icon.icns (auto via electron-builder on macOS)"); |
| 221 | + |
| 222 | + const sW = 164, sH = 314; |
| 223 | + const sidebarRaw = await sharp(Buffer.from(sidebarSvg)) |
| 224 | + .resize(sW, sH).ensureAlpha().raw().toBuffer(); |
| 225 | + writeFileSync(join(buildDir, "installerSidebar.bmp"), rawToBmp24(sidebarRaw, sW, sH)); |
| 226 | + console.log(" installerSidebar.bmp 164x314"); |
| 227 | + |
| 228 | + const hW = 150, hH = 57; |
| 229 | + const headerRaw = await sharp(Buffer.from(headerSvg)) |
| 230 | + .resize(hW, hH).ensureAlpha().raw().toBuffer(); |
| 231 | + writeFileSync(join(buildDir, "installerHeader.bmp"), rawToBmp24(headerRaw, hW, hH)); |
| 232 | + console.log(" installerHeader.bmp 150x57"); |
| 233 | + |
| 234 | + console.log("\nDone! All assets in build/"); |
115 | 235 | } |
116 | 236 |
|
117 | 237 | generate().catch((err) => { |
|
0 commit comments