@istic-co/annealer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aquarion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Annealer
2
+
3
+ Render an Apple Icon Composer bundle (`.icon`) and its companion web
4
+ favicons in plain Node.js — no Xcode, no `actool`, no macOS runner.
5
+
6
+ Annealer is a from-scratch reimplementation of Icon Composer's rendering
7
+ (squircle masking, gradient compensation, Display P3 color handling, and
8
+ iOS 26 "liquid glass" specular effects), reverse-engineered empirically
9
+ against Icon Composer's actual output. It ships as an npm package, a CLI,
10
+ and a GitHub Action.
11
+
12
+ ## Supported icon shape
13
+
14
+ Annealer currently supports exactly one Icon Composer shape:
15
+
16
+ - A single `fill['automatic-gradient']` value (no `flat-color` fill, no
17
+ multi-stop fills).
18
+ - Exactly one layer group, with `glass: true` on its layer (no
19
+ `glass: false` rendering path).
20
+ - A glyph SVG with exactly one `<path d="...">` element (no multi-path or
21
+ multi-group glyphs).
22
+
23
+ Icons outside this shape will render incorrectly rather than fail loudly.
24
+ See the [issue tracker](https://github.com/istic/annealer/issues) for
25
+ tracked gaps, and feel free to open a PR to extend support.
26
+
27
+ The web-icon filenames (`bloom-standard.svg`, `bloom-standard.png`,
28
+ `bloom-on-white.png`) are currently hardcoded from Annealer's origin
29
+ project and not yet configurable — also tracked in the issue tracker.
30
+
31
+ ## Usage
32
+
33
+ ### As a GitHub Action
34
+
35
+ ```yaml
36
+ - uses: istic/annealer@v1
37
+ with:
38
+ icon-path: resources/branding/my-app.icon
39
+ glyph: resources/branding/glyph.svg
40
+ background-color: '#6A2AAC'
41
+ output-dir: resources/icons
42
+ ```
43
+
44
+ ### As a CLI
45
+
46
+ ```sh
47
+ npx @istic-co/annealer \
48
+ --icon-path resources/branding/my-app.icon \
49
+ --glyph resources/branding/glyph.svg \
50
+ --background-color '#6A2AAC' \
51
+ --output-dir resources/icons
52
+ ```
53
+
54
+ ### As an npm package
55
+
56
+ ```js
57
+ import { generateAppleTouchIcon, generateWebIcons } from '@istic-co/annealer';
58
+
59
+ const config = {
60
+ iconPath: 'resources/branding/my-app.icon',
61
+ glyph: 'resources/branding/glyph.svg',
62
+ backgroundColor: '#6A2AAC',
63
+ };
64
+
65
+ await generateAppleTouchIcon(config, 'resources/icons');
66
+ await generateWebIcons(config, 'resources/icons');
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```sh
72
+ npm install
73
+ npm test
74
+ ```
package/action.yml ADDED
@@ -0,0 +1,51 @@
1
+ name: 'Annealer'
2
+ description: 'Render Apple Icon Composer bundles and web favicons in plain Node.js — no Xcode required.'
3
+ branding:
4
+ icon: 'image'
5
+ color: 'purple'
6
+ inputs:
7
+ icon-path:
8
+ description: 'Path to the Apple Icon Composer (.icon) bundle. Required for the apple/all targets.'
9
+ required: false
10
+ default: ''
11
+ glyph:
12
+ description: 'Path to the glyph SVG used for web icon rendering. Required for the web/all targets.'
13
+ required: false
14
+ default: ''
15
+ background-color:
16
+ description: 'Background color as a hex string, e.g. #6A2AAC.'
17
+ required: true
18
+ output-dir:
19
+ description: 'Directory to write generated icons into.'
20
+ required: false
21
+ default: 'resources/icons'
22
+ target:
23
+ description: 'Which icons to generate: apple, web, or all.'
24
+ required: false
25
+ default: 'all'
26
+ runs:
27
+ using: 'composite'
28
+ steps:
29
+ - name: Install Node.js
30
+ uses: actions/setup-node@v4
31
+ with:
32
+ node-version: '22'
33
+ - name: Install Annealer's dependencies
34
+ shell: bash
35
+ run: npm ci --prefix "${{ github.action_path }}"
36
+ - name: Generate icons
37
+ shell: bash
38
+ env:
39
+ ANNEALER_ICON_PATH: ${{ inputs.icon-path }}
40
+ ANNEALER_GLYPH: ${{ inputs.glyph }}
41
+ ANNEALER_BACKGROUND_COLOR: ${{ inputs.background-color }}
42
+ ANNEALER_OUTPUT_DIR: ${{ inputs.output-dir }}
43
+ ANNEALER_TARGET: ${{ inputs.target }}
44
+ ANNEALER_ACTION_PATH: ${{ github.action_path }}
45
+ run: |
46
+ node "$ANNEALER_ACTION_PATH/bin/cli.js" \
47
+ --icon-path="$ANNEALER_ICON_PATH" \
48
+ --glyph="$ANNEALER_GLYPH" \
49
+ --background-color="$ANNEALER_BACKGROUND_COLOR" \
50
+ --output-dir="$ANNEALER_OUTPUT_DIR" \
51
+ --target="$ANNEALER_TARGET"
package/bin/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /* global process */
3
+ import { parseArgs } from 'node:util';
4
+ import { generateAppleTouchIcon, generateWebIcons } from '../src/index.js';
5
+
6
+ const { values } = parseArgs({
7
+ options: {
8
+ 'icon-path': { type: 'string', default: '' },
9
+ glyph: { type: 'string', default: '' },
10
+ 'background-color': { type: 'string', default: '' },
11
+ 'output-dir': { type: 'string', default: 'resources/icons' },
12
+ target: { type: 'string', default: 'all' },
13
+ },
14
+ });
15
+
16
+ export async function main() {
17
+ if (!values['background-color']) {
18
+ throw new Error('--background-color is required');
19
+ }
20
+
21
+ const config = {
22
+ backgroundColor: values['background-color'],
23
+ iconPath: values['icon-path'],
24
+ glyph: values.glyph,
25
+ };
26
+
27
+ if (values.target === 'apple' || values.target === 'all') {
28
+ if (!config.iconPath) {
29
+ throw new Error('--icon-path is required for the apple target');
30
+ }
31
+
32
+ await generateAppleTouchIcon(config, values['output-dir']);
33
+ }
34
+
35
+ if (values.target === 'web' || values.target === 'all') {
36
+ if (!config.glyph) {
37
+ throw new Error('--glyph is required for the web target');
38
+ }
39
+
40
+ await generateWebIcons(config, values['output-dir']);
41
+ }
42
+ }
43
+
44
+ main().catch((error) => {
45
+ console.error(error.message);
46
+ process.exitCode = 1;
47
+ });
@@ -0,0 +1,55 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ const CLI_PATH = path.join(import.meta.dirname, 'cli.js');
10
+ const FIXTURE_ICON_DIR = path.join(import.meta.dirname, '..', 'src', 'test-fixtures', 'sample.icon');
11
+ const FIXTURE_GLYPH = path.join(import.meta.dirname, '..', 'src', 'test-fixtures', 'glyph.svg');
12
+
13
+ let outputDir;
14
+
15
+ afterEach(async () => {
16
+ if (outputDir) {
17
+ await fs.rm(outputDir, { recursive: true, force: true });
18
+ outputDir = undefined;
19
+ }
20
+ });
21
+
22
+ describe('cli', () => {
23
+ it(
24
+ 'generates both apple and web icons for target=all',
25
+ async () => {
26
+ outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-cli-'));
27
+
28
+ await execFileAsync('node', [
29
+ CLI_PATH,
30
+ '--icon-path', FIXTURE_ICON_DIR,
31
+ '--glyph', FIXTURE_GLYPH,
32
+ '--background-color', '#6A2AAC',
33
+ '--output-dir', outputDir,
34
+ '--target', 'all',
35
+ ]);
36
+
37
+ const files = await fs.readdir(outputDir);
38
+
39
+ expect(files).toContain('apple-touch-icon.png');
40
+ expect(files).toContain('favicon.ico');
41
+ },
42
+ 20000,
43
+ );
44
+
45
+ it('exits non-zero with a clear message when required flags are missing', async () => {
46
+ outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-cli-'));
47
+
48
+ await expect(
49
+ execFileAsync('node', [CLI_PATH, '--output-dir', outputDir, '--target', 'apple']),
50
+ ).rejects.toMatchObject({
51
+ code: 1,
52
+ stderr: expect.stringContaining('background-color is required'),
53
+ });
54
+ });
55
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@istic-co/annealer",
3
+ "version": "0.1.0",
4
+ "description": "Render Apple Icon Composer bundles and web favicons in plain Node.js — no Xcode required.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Aquarion",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/istic/annealer.git"
11
+ },
12
+ "main": "src/index.js",
13
+ "exports": {
14
+ ".": "./src/index.js"
15
+ },
16
+ "bin": {
17
+ "annealer": "bin/cli.js"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "bin",
22
+ "action.yml"
23
+ ],
24
+ "scripts": {
25
+ "test": "vitest run"
26
+ },
27
+ "dependencies": {
28
+ "sharp": "^0.35.3"
29
+ },
30
+ "devDependencies": {
31
+ "vitest": "^4.1.10"
32
+ }
33
+ }
package/src/colors.js ADDED
@@ -0,0 +1,71 @@
1
+ // D65 matrices, ported from regen-icons.sh's hex_to_display_p3 Python heredoc.
2
+ const SRGB_TO_XYZ = [
3
+ [0.4124564, 0.3575761, 0.1804375],
4
+ [0.2126729, 0.7151522, 0.072175],
5
+ [0.0193339, 0.119192, 0.9503041],
6
+ ];
7
+
8
+ const XYZ_TO_P3 = [
9
+ [2.4934969, -0.9313836, -0.4027108],
10
+ [-0.829489, 1.762664, 0.0236247],
11
+ [0.0358458, -0.0761724, 0.9568845],
12
+ ];
13
+
14
+ // Empirical per-channel darkening observed in apple-touch-icon output vs the
15
+ // requested background color, ported from regen-icons.sh's DARKEN constant.
16
+ const APPLE_RENDER_DARKEN = [
17
+ 0.6022727272727273, 0.4375, 0.7962962962962963,
18
+ ];
19
+
20
+ function multiply(matrix, vector) {
21
+ return matrix.map((row) =>
22
+ row.reduce((sum, value, index) => sum + value * vector[index], 0),
23
+ );
24
+ }
25
+
26
+ function srgbToLinear(channel) {
27
+ return channel <= 0.04045
28
+ ? channel / 12.92
29
+ : ((channel + 0.055) / 1.055) ** 2.4;
30
+ }
31
+
32
+ export function hexToRgb(hex) {
33
+ const raw = hex.replace('#', '');
34
+
35
+ return [0, 2, 4].map((offset) => Number.parseInt(raw.slice(offset, offset + 2), 16));
36
+ }
37
+
38
+ export function rgbToHex([r, g, b]) {
39
+ return `#${[r, g, b]
40
+ .map((channel) => channel.toString(16).padStart(2, '0').toUpperCase())
41
+ .join('')}`;
42
+ }
43
+
44
+ export function hexToDisplayP3(hex) {
45
+ const linear = hexToRgb(hex).map((channel) => srgbToLinear(channel / 255));
46
+ const xyz = multiply(SRGB_TO_XYZ, linear);
47
+ const p3 = multiply(XYZ_TO_P3, xyz).map((channel) => Math.min(1, Math.max(0, channel)));
48
+
49
+ return `display-p3:${p3.map((channel) => channel.toFixed(5)).join(',')},1.00000`;
50
+ }
51
+
52
+ export function compensateForAppleRender(hex) {
53
+ const compensated = hexToRgb(hex).map((channel, index) =>
54
+ Math.min(255, Math.max(0, Math.round(channel / APPLE_RENDER_DARKEN[index]))),
55
+ );
56
+
57
+ return rgbToHex(compensated);
58
+ }
59
+
60
+ // Apple's icon tool stores gamma-encoded P3 components directly in the sRGB
61
+ // container without gamut conversion. This replicates that quirk so the
62
+ // background color used for icon rendering matches the old pipeline exactly.
63
+ export function p3StringToAppleRgb(p3Str) {
64
+ const match = p3Str.match(/display-p3:([\d.]+),([\d.]+),([\d.]+)/);
65
+
66
+ if (!match) {
67
+ throw new Error(`p3StringToAppleRgb: cannot parse "${p3Str}"`);
68
+ }
69
+
70
+ return [match[1], match[2], match[3]].map((v) => Math.round(Number(v) * 255));
71
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compensateForAppleRender, hexToDisplayP3, hexToRgb, p3StringToAppleRgb } from './colors.js';
3
+
4
+ describe('hexToRgb', () => {
5
+ it('parses a hex string into RGB channel values', () => {
6
+ expect(hexToRgb('#6A2AAC')).toEqual([106, 42, 172]);
7
+ });
8
+ });
9
+
10
+ describe('hexToDisplayP3', () => {
11
+ it('converts the brand purple to a Display P3 string', () => {
12
+ expect(hexToDisplayP3('#6A2AAC')).toBe(
13
+ 'display-p3:0.12267,0.02717,0.37968,1.00000',
14
+ );
15
+ });
16
+
17
+ it('converts black to zeroed P3 components', () => {
18
+ expect(hexToDisplayP3('#000000')).toBe(
19
+ 'display-p3:0.00000,0.00000,0.00000,1.00000',
20
+ );
21
+ });
22
+ });
23
+
24
+ describe('compensateForAppleRender', () => {
25
+ it('lightens the brand purple to counter Apple darkening', () => {
26
+ expect(compensateForAppleRender('#6A2AAC')).toBe('#B060D8');
27
+ });
28
+
29
+ it('leaves black and white unaffected', () => {
30
+ expect(compensateForAppleRender('#000000')).toBe('#000000');
31
+ expect(compensateForAppleRender('#FFFFFF')).toBe('#FFFFFF');
32
+ });
33
+ });
34
+
35
+ describe('p3StringToAppleRgb', () => {
36
+ it('treats P3 components as sRGB matching Apple icon tool quirk', () => {
37
+ // display-p3:0.37790,0.12750,0.64098 -> rgb(96, 33, 163) not rgb(176, 96, 216)
38
+ expect(p3StringToAppleRgb('display-p3:0.37790,0.12750,0.64098,1.00000')).toEqual([96, 33, 163]);
39
+ });
40
+ });
@@ -0,0 +1,194 @@
1
+ /* global Buffer */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import sharp from 'sharp';
5
+ import { compensateForAppleRender, hexToDisplayP3, p3StringToAppleRgb } from './colors.js';
6
+ import { generateSquirclePath } from './squircle.js';
7
+
8
+ const DEFAULT_OUTPUT_DIR = 'resources/icons';
9
+ const SIZE = 1024;
10
+
11
+ // Apple's "automatic-gradient" lightens the top of the icon by ~40 RGB units,
12
+ // reaching the base color at ~70% of the height and staying flat below that.
13
+ const GRADIENT_LIFT = 40;
14
+
15
+ async function fileExists(filePath) {
16
+ try {
17
+ await fs.access(filePath);
18
+
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function syncIconJsonGradient(jsonPath, compensatedHex, write = true) {
26
+ const iconData = JSON.parse(await fs.readFile(jsonPath, 'utf-8'));
27
+
28
+ iconData.fill = { ...iconData.fill, 'automatic-gradient': hexToDisplayP3(compensatedHex) };
29
+
30
+ if (write) {
31
+ await fs.writeFile(jsonPath, `${JSON.stringify(iconData, null, 2)}\n`, 'utf-8');
32
+ }
33
+
34
+ return iconData;
35
+ }
36
+
37
+ function backgroundLayer(rgb) {
38
+ const [r, g, b] = rgb;
39
+ const baseColor = `rgb(${r}, ${g}, ${b})`;
40
+ const topColor = `rgb(${Math.min(255, r + GRADIENT_LIFT)}, ${Math.min(255, g + GRADIENT_LIFT)}, ${Math.min(255, b + GRADIENT_LIFT)})`;
41
+
42
+ const squircleMask = Buffer.from(`
43
+ <svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
44
+ <path d="${generateSquirclePath(SIZE, 5)}" fill="white" />
45
+ </svg>
46
+ `);
47
+
48
+ const gradient = Buffer.from(`
49
+ <svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
50
+ <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
51
+ <stop offset="0%" style="stop-color:${topColor}" />
52
+ <stop offset="70%" style="stop-color:${baseColor}" />
53
+ <stop offset="100%" style="stop-color:${baseColor}" />
54
+ </linearGradient>
55
+ <rect width="${SIZE}" height="${SIZE}" fill="url(#grad)" />
56
+ </svg>
57
+ `);
58
+
59
+ return sharp(gradient).composite([{ input: squircleMask, blend: 'dest-in' }]).png().toBuffer();
60
+ }
61
+
62
+ async function glyphLayer(iconDir, group, layer) {
63
+ const imagePath = path.join(iconDir, 'Assets', layer['image-name']);
64
+
65
+ if (!(await fileExists(imagePath))) {
66
+ return null;
67
+ }
68
+
69
+ const originalSvg = await fs.readFile(imagePath, 'utf-8');
70
+ const pathMatch = originalSvg.match(/<path d="([^"]+)"/);
71
+
72
+ if (!pathMatch) {
73
+ return null;
74
+ }
75
+
76
+ const scale = layer.position?.scale || 1.0;
77
+ // Apple's icon JSON expresses scale as a "coverage" fraction; its renderer
78
+ // maps that to an effective layer size via a power curve — exponent ~0.35
79
+ // empirically matches Xcode's output across the observable scale range.
80
+ const renderedScale = scale ** 0.35;
81
+ const layerSize = Math.round(SIZE * renderedScale);
82
+ const layerOffset = Math.round((SIZE - layerSize) / 2);
83
+
84
+ // Apple's translucency is a frosted-glass blend, not simple fill-opacity.
85
+ // The interior petal pixels in the reference output match ~0.70 opacity for
86
+ // translucency=0.5; specular highlights then push bright edges toward white.
87
+ const translucency = group.translucency?.enabled ? (group.translucency.value ?? 0.5) : 1.0;
88
+ const layerOpacity = Math.min(1.0, 0.4 + translucency * 0.55);
89
+
90
+ const glassGlyphSvg = `
91
+ <svg width="${layerSize}" height="${layerSize}" viewBox="0 0 1200 1200">
92
+ <defs>
93
+ <filter id="liquidGlass" x="-15%" y="-15%" width="130%" height="130%">
94
+ <feGaussianBlur in="SourceAlpha" stdDeviation="14" result="glowBlur" />
95
+ <feFlood flood-color="white" flood-opacity="0.3" result="glowFill" />
96
+ <feComposite in="glowFill" in2="glowBlur" operator="in" result="outerGlow" />
97
+ <feGaussianBlur in="SourceAlpha" stdDeviation="16" result="bump" />
98
+ <feSpecularLighting in="bump" surfaceScale="6" specularConstant="3" specularExponent="25" lighting-color="white" result="spec">
99
+ <fePointLight x="-300" y="-500" z="900" />
100
+ </feSpecularLighting>
101
+ <feComposite in="spec" in2="SourceAlpha" operator="in" result="specLight" />
102
+ <feMerge>
103
+ <feMergeNode in="outerGlow" />
104
+ <feMergeNode in="SourceGraphic" />
105
+ <feMergeNode in="specLight" />
106
+ </feMerge>
107
+ </filter>
108
+ </defs>
109
+ <path d="${pathMatch[1]}" fill="white" fill-opacity="${layerOpacity}" filter="url(#liquidGlass)" />
110
+ </svg>
111
+ `;
112
+
113
+ const input = await sharp(Buffer.from(glassGlyphSvg)).png().toBuffer();
114
+
115
+ return { input, top: layerOffset, left: layerOffset };
116
+ }
117
+
118
+ // Apple's squircle has a ~20px bright specular highlight along all edges,
119
+ // clipped to the squircle boundary: feMorphology erode carves a border ring,
120
+ // then a Gaussian blur softens it inward.
121
+ async function edgeGlowLayer() {
122
+ const svg = `
123
+ <svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
124
+ <defs>
125
+ <filter id="edgeGlow" x="0%" y="0%" width="100%" height="100%">
126
+ <feMorphology in="SourceAlpha" operator="erode" radius="16" result="eroded" />
127
+ <feComposite in="SourceAlpha" in2="eroded" operator="arithmetic" k2="1" k3="-1" result="ring" />
128
+ <feGaussianBlur in="ring" stdDeviation="7" result="soft" />
129
+ <feFlood flood-color="white" flood-opacity="0.6" result="white" />
130
+ <feComposite in="white" in2="soft" operator="in" result="glow" />
131
+ <feComposite in="glow" in2="SourceAlpha" operator="in" />
132
+ </filter>
133
+ </defs>
134
+ <path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#edgeGlow)" />
135
+ </svg>
136
+ `;
137
+
138
+ return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
139
+ }
140
+
141
+ // Apple's top-left corner has a stronger, crisper highlight. surfaceScale=51
142
+ // compensates for librsvg normalising bump gradients by 255; the low z=80
143
+ // point light makes interior normals near-zero while the TL corner's outward
144
+ // normal aligns with the light, creating a highlight that fades at TR/BR.
145
+ async function cornerSpecularLayer() {
146
+ const svg = `
147
+ <svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
148
+ <defs>
149
+ <filter id="cornerSpec" x="0%" y="0%" width="100%" height="100%">
150
+ <feGaussianBlur in="SourceAlpha" stdDeviation="12" result="bump" />
151
+ <feSpecularLighting in="bump" surfaceScale="51" specularConstant="0.65" specularExponent="8" lighting-color="white" result="spec">
152
+ <fePointLight x="-100" y="-100" z="80" />
153
+ </feSpecularLighting>
154
+ <feComposite in="spec" in2="SourceAlpha" operator="in" />
155
+ </filter>
156
+ </defs>
157
+ <path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#cornerSpec)" />
158
+ </svg>
159
+ `;
160
+
161
+ return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
162
+ }
163
+
164
+ export async function generateAppleTouchIcon(config, outputDir = DEFAULT_OUTPUT_DIR, { syncJson = true } = {}) {
165
+ const iconDir = config.iconPath;
166
+ const jsonPath = path.join(iconDir, 'icon.json');
167
+ const compensatedHex = compensateForAppleRender(config.backgroundColor);
168
+ const iconData = await syncIconJsonGradient(jsonPath, compensatedHex, syncJson);
169
+ // Replicate Apple's icon tool quirk: P3 components are stored in the sRGB
170
+ // container without gamut conversion, so we read them back the same way.
171
+ const rgb = p3StringToAppleRgb(iconData.fill['automatic-gradient']);
172
+
173
+ const composites = [{ input: await backgroundLayer(rgb), top: 0, left: 0 }];
174
+
175
+ for (const group of iconData.groups || []) {
176
+ for (const layer of group.layers || []) {
177
+ const composite = await glyphLayer(iconDir, group, layer);
178
+
179
+ if (composite) {
180
+ composites.push(composite);
181
+ }
182
+ }
183
+ }
184
+
185
+ composites.push(await edgeGlowLayer());
186
+ composites.push(await cornerSpecularLayer());
187
+
188
+ await fs.mkdir(outputDir, { recursive: true });
189
+ await sharp({
190
+ create: { width: SIZE, height: SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
191
+ })
192
+ .composite(composites)
193
+ .toFile(path.join(outputDir, 'apple-touch-icon.png'));
194
+ }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import sharp from 'sharp';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { compensateForAppleRender, hexToDisplayP3 } from './colors.js';
7
+ import { generateAppleTouchIcon } from './generate-apple-touch-icon.js';
8
+
9
+ const FIXTURE_ICON_DIR = path.join(import.meta.dirname, 'test-fixtures', 'sample.icon');
10
+ const CONFIG = { backgroundColor: '#6A2AAC' };
11
+
12
+ let outputDir;
13
+ let iconDir;
14
+
15
+ beforeEach(async () => {
16
+ iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-icon-'));
17
+ await fs.cp(FIXTURE_ICON_DIR, iconDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await fs.rm(iconDir, { recursive: true, force: true });
22
+
23
+ if (outputDir) {
24
+ await fs.rm(outputDir, { recursive: true, force: true });
25
+ outputDir = undefined;
26
+ }
27
+ });
28
+
29
+ describe('generateAppleTouchIcon', () => {
30
+ it(
31
+ 'renders a 1024x1024 RGBA PNG',
32
+ async () => {
33
+ outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-apple-icon-'));
34
+
35
+ await generateAppleTouchIcon({ ...CONFIG, iconPath: iconDir }, outputDir);
36
+
37
+ const { width, height, channels } = await sharp(path.join(outputDir, 'apple-touch-icon.png')).metadata();
38
+
39
+ expect({ width, height, channels }).toEqual({ width: 1024, height: 1024, channels: 4 });
40
+ },
41
+ 20000,
42
+ );
43
+
44
+ it(
45
+ "syncs icon.json's automatic-gradient to the compensated brand color",
46
+ async () => {
47
+ outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-apple-icon-'));
48
+
49
+ await generateAppleTouchIcon({ ...CONFIG, iconPath: iconDir }, outputDir);
50
+
51
+ const iconData = JSON.parse(await fs.readFile(path.join(iconDir, 'icon.json'), 'utf-8'));
52
+
53
+ expect(iconData.fill['automatic-gradient']).toBe(hexToDisplayP3(compensateForAppleRender(CONFIG.backgroundColor)));
54
+ },
55
+ 20000,
56
+ );
57
+ });
@@ -0,0 +1,61 @@
1
+ /* global Buffer */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import sharp from 'sharp';
5
+ import { packIco } from './pack-ico.js';
6
+
7
+ const DEFAULT_OUTPUT_DIR = 'resources/icons';
8
+ const CANVAS_SIZE = 1200;
9
+ const FAVICON_SIZES = [16, 32, 48, 64, 128, 256, 512];
10
+ const WHITE = '#FFFFFF';
11
+
12
+ function extractGlyphMarkup(svgMarkup) {
13
+ const match = svgMarkup.match(/<svg\b[^>]*>([\s\S]*)<\/svg>/i);
14
+
15
+ if (!match) {
16
+ throw new Error('extractGlyphMarkup: unable to parse glyph SVG');
17
+ }
18
+
19
+ return match[1].trim();
20
+ }
21
+
22
+ function buildCanvasSvg(glyphMarkup, backgroundColor) {
23
+ return `<?xml version="1.0" encoding="UTF-8"?>
24
+ <svg viewBox="0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}" xmlns="http://www.w3.org/2000/svg">
25
+ <rect x="0" y="0" width="${CANVAS_SIZE}" height="${CANVAS_SIZE}" fill="${backgroundColor}"/>
26
+ ${glyphMarkup}
27
+ </svg>`;
28
+ }
29
+
30
+ async function writeOutput(outputDir, name, contents) {
31
+ await fs.writeFile(path.join(outputDir, name), contents);
32
+ }
33
+
34
+ async function renderPng(svgBuffer, size) {
35
+ return sharp(svgBuffer).resize(size, size).png().toBuffer();
36
+ }
37
+
38
+ export async function generateWebIcons(config, outputDir = DEFAULT_OUTPUT_DIR) {
39
+ const glyphSource = await fs.readFile(config.glyph, 'utf-8');
40
+ const glyphMarkup = extractGlyphMarkup(glyphSource);
41
+
42
+ const standardSvg = buildCanvasSvg(glyphMarkup, config.backgroundColor);
43
+ const onWhiteSvg = buildCanvasSvg(glyphMarkup, WHITE);
44
+ const standardBuffer = Buffer.from(standardSvg);
45
+ const onWhiteBuffer = Buffer.from(onWhiteSvg);
46
+
47
+ await fs.mkdir(outputDir, { recursive: true });
48
+
49
+ const faviconFrames = await Promise.all(FAVICON_SIZES.map((size) => renderPng(standardBuffer, size)));
50
+
51
+ await Promise.all([
52
+ writeOutput(outputDir, 'favicon.svg', standardSvg),
53
+ writeOutput(outputDir, 'bloom-standard.svg', standardSvg),
54
+ writeOutput(outputDir, 'favicon.ico', packIco(faviconFrames)),
55
+ renderPng(standardBuffer, 96).then((png) => writeOutput(outputDir, 'favicon-96x96.png', png)),
56
+ renderPng(standardBuffer, 1200).then((png) => writeOutput(outputDir, 'bloom-standard.png', png)),
57
+ renderPng(onWhiteBuffer, 1200).then((png) => writeOutput(outputDir, 'bloom-on-white.png', png)),
58
+ renderPng(standardBuffer, 192).then((png) => writeOutput(outputDir, 'web-app-manifest-192x192.png', png)),
59
+ renderPng(standardBuffer, 512).then((png) => writeOutput(outputDir, 'web-app-manifest-512x512.png', png)),
60
+ ]);
61
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import sharp from 'sharp';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { generateWebIcons } from './generate-web-icons.js';
7
+
8
+ const FIXTURE_GLYPH = path.join(import.meta.dirname, 'test-fixtures', 'glyph.svg');
9
+ const CONFIG = { glyph: FIXTURE_GLYPH, backgroundColor: '#6A2AAC' };
10
+
11
+ let outputDir;
12
+
13
+ afterEach(async () => {
14
+ if (outputDir) {
15
+ await fs.rm(outputDir, { recursive: true, force: true });
16
+ outputDir = undefined;
17
+ }
18
+ });
19
+
20
+ async function freshOutputDir() {
21
+ outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-web-icons-'));
22
+
23
+ return outputDir;
24
+ }
25
+
26
+ describe('generateWebIcons', () => {
27
+ it('renders every PNG variant at its expected size', async () => {
28
+ const dir = await freshOutputDir();
29
+
30
+ await generateWebIcons(CONFIG, dir);
31
+
32
+ const expectations = {
33
+ 'favicon-96x96.png': { width: 96, height: 96 },
34
+ 'bloom-standard.png': { width: 1200, height: 1200 },
35
+ 'bloom-on-white.png': { width: 1200, height: 1200 },
36
+ 'web-app-manifest-192x192.png': { width: 192, height: 192 },
37
+ 'web-app-manifest-512x512.png': { width: 512, height: 512 },
38
+ };
39
+
40
+ for (const [name, expected] of Object.entries(expectations)) {
41
+ const { width, height } = await sharp(path.join(dir, name)).metadata();
42
+
43
+ expect({ width, height }).toEqual(expected);
44
+ }
45
+ });
46
+
47
+ it('writes the SVG variants', async () => {
48
+ const dir = await freshOutputDir();
49
+
50
+ await generateWebIcons(CONFIG, dir);
51
+
52
+ const favicon = await fs.readFile(path.join(dir, 'favicon.svg'), 'utf-8');
53
+ const standard = await fs.readFile(path.join(dir, 'bloom-standard.svg'), 'utf-8');
54
+
55
+ expect(favicon).toBe(standard);
56
+ expect(favicon).toContain('fill="#6A2AAC"');
57
+ expect(favicon).toContain('<circle');
58
+ });
59
+
60
+ it('renders favicon.ico with the full multi-resolution frame set', async () => {
61
+ const dir = await freshOutputDir();
62
+
63
+ await generateWebIcons(CONFIG, dir);
64
+
65
+ const ico = await fs.readFile(path.join(dir, 'favicon.ico'));
66
+
67
+ expect(ico.readUInt16LE(4)).toBe(7);
68
+ });
69
+
70
+ it('fills the standard variant with the configured background color', async () => {
71
+ const dir = await freshOutputDir();
72
+
73
+ await generateWebIcons(CONFIG, dir);
74
+
75
+ const { data } = await sharp(path.join(dir, 'favicon-96x96.png'))
76
+ .raw()
77
+ .toBuffer({ resolveWithObject: true });
78
+
79
+ // Top-left corner sits outside the centered glyph, so it's pure background.
80
+ expect([data[0], data[1], data[2]]).toEqual([0x6a, 0x2a, 0xac]);
81
+ });
82
+
83
+ it('fills the "on white" variant with a white background', async () => {
84
+ const dir = await freshOutputDir();
85
+
86
+ await generateWebIcons(CONFIG, dir);
87
+
88
+ const { data } = await sharp(path.join(dir, 'bloom-on-white.png'))
89
+ .raw()
90
+ .toBuffer({ resolveWithObject: true });
91
+
92
+ expect([data[0], data[1], data[2]]).toEqual([0xff, 0xff, 0xff]);
93
+ });
94
+ });
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { compensateForAppleRender, hexToDisplayP3, hexToRgb, p3StringToAppleRgb, rgbToHex } from './colors.js';
2
+ export { generateSquirclePath } from './squircle.js';
3
+ export { packIco, readPngDimensions } from './pack-ico.js';
4
+ export { generateAppleTouchIcon } from './generate-apple-touch-icon.js';
5
+ export { generateWebIcons } from './generate-web-icons.js';
@@ -0,0 +1,48 @@
1
+ /* global Buffer */
2
+
3
+ const HEADER_SIZE = 6;
4
+ const DIRECTORY_ENTRY_SIZE = 16;
5
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
+
7
+ export function readPngDimensions(png) {
8
+ if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
9
+ throw new Error('readPngDimensions: buffer is not a PNG image');
10
+ }
11
+
12
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
13
+ }
14
+
15
+ // Builds a multi-frame .ico container directly from PNG buffers — the format
16
+ // every modern browser and OS supports since Windows Vista. Frames at or above
17
+ // 256px store 0 in the (single-byte) directory width/height fields; readers
18
+ // fall back to the embedded PNG's own dimensions, which is exactly how
19
+ // ImageMagick encodes the existing favicon.ico's 512px frame.
20
+ export function packIco(pngBuffers) {
21
+ const directorySize = DIRECTORY_ENTRY_SIZE * pngBuffers.length;
22
+ const header = Buffer.alloc(HEADER_SIZE);
23
+
24
+ header.writeUInt16LE(0, 0); // reserved
25
+ header.writeUInt16LE(1, 2); // type: icon
26
+ header.writeUInt16LE(pngBuffers.length, 4);
27
+
28
+ const directory = Buffer.alloc(directorySize);
29
+ let dataOffset = HEADER_SIZE + directorySize;
30
+
31
+ pngBuffers.forEach((png, index) => {
32
+ const { width, height } = readPngDimensions(png);
33
+ const entry = index * DIRECTORY_ENTRY_SIZE;
34
+
35
+ directory.writeUInt8(width >= 256 ? 0 : width, entry);
36
+ directory.writeUInt8(height >= 256 ? 0 : height, entry + 1);
37
+ directory.writeUInt8(0, entry + 2); // palette colors (none)
38
+ directory.writeUInt8(0, entry + 3); // reserved
39
+ directory.writeUInt16LE(1, entry + 4); // color planes
40
+ directory.writeUInt16LE(32, entry + 6); // bits per pixel
41
+ directory.writeUInt32LE(png.length, entry + 8);
42
+ directory.writeUInt32LE(dataOffset, entry + 12);
43
+
44
+ dataOffset += png.length;
45
+ });
46
+
47
+ return Buffer.concat([header, directory, ...pngBuffers]);
48
+ }
@@ -0,0 +1,58 @@
1
+ import sharp from 'sharp';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { packIco, readPngDimensions } from './pack-ico.js';
4
+
5
+ async function solidPng(size) {
6
+ return sharp({
7
+ create: {
8
+ width: size,
9
+ height: size,
10
+ channels: 4,
11
+ background: { r: 255, g: 0, b: 0, alpha: 1 },
12
+ },
13
+ })
14
+ .png()
15
+ .toBuffer();
16
+ }
17
+
18
+ describe('readPngDimensions', () => {
19
+ it('reads width and height from a PNG buffer', async () => {
20
+ const png = await solidPng(32);
21
+
22
+ expect(readPngDimensions(png)).toEqual({ width: 32, height: 32 });
23
+ });
24
+ });
25
+
26
+ describe('packIco', () => {
27
+ it('builds an ICO container with one directory entry per frame', async () => {
28
+ const sizes = [16, 256, 512];
29
+ const frames = await Promise.all(sizes.map(solidPng));
30
+ const ico = packIco(frames);
31
+
32
+ expect(ico.readUInt16LE(0)).toBe(0); // reserved
33
+ expect(ico.readUInt16LE(2)).toBe(1); // type: icon
34
+ expect(ico.readUInt16LE(4)).toBe(sizes.length);
35
+
36
+ let dataOffset = 6 + 16 * sizes.length;
37
+
38
+ sizes.forEach((size, index) => {
39
+ const entry = 6 + index * 16;
40
+ const expectedDimensionByte = size >= 256 ? 0 : size;
41
+
42
+ expect(ico.readUInt8(entry)).toBe(expectedDimensionByte);
43
+ expect(ico.readUInt8(entry + 1)).toBe(expectedDimensionByte);
44
+ expect(ico.readUInt16LE(entry + 4)).toBe(1); // color planes
45
+ expect(ico.readUInt16LE(entry + 6)).toBe(32); // bits per pixel
46
+ expect(ico.readUInt32LE(entry + 8)).toBe(frames[index].length);
47
+ expect(ico.readUInt32LE(entry + 12)).toBe(dataOffset);
48
+
49
+ const embedded = ico.subarray(dataOffset, dataOffset + frames[index].length);
50
+
51
+ expect(readPngDimensions(embedded)).toEqual({ width: size, height: size });
52
+
53
+ dataOffset += frames[index].length;
54
+ });
55
+
56
+ expect(ico.length).toBe(dataOffset);
57
+ });
58
+ });
@@ -0,0 +1,19 @@
1
+ // Quintic superellipse (n=5 by convention here) path generator matching
2
+ // Apple's squircle shape, traced in one-degree steps from the rightmost point.
3
+ export function generateSquirclePath(size, exponent) {
4
+ const radius = size / 2;
5
+ const center = size / 2;
6
+ let path = `M ${radius + center},${center} `;
7
+
8
+ for (let degrees = 0; degrees <= 360; degrees += 1) {
9
+ const angle = (degrees * Math.PI) / 180;
10
+ const cos = Math.cos(angle);
11
+ const sin = Math.sin(angle);
12
+ const x = Math.abs(cos) ** (2 / exponent) * radius * Math.sign(cos) + center;
13
+ const y = Math.abs(sin) ** (2 / exponent) * radius * Math.sign(sin) + center;
14
+
15
+ path += `L ${x},${y} `;
16
+ }
17
+
18
+ return `${path}Z`;
19
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateSquirclePath } from './squircle.js';
3
+
4
+ describe('generateSquirclePath', () => {
5
+ it('starts at the rightmost point and closes the path', () => {
6
+ const path = generateSquirclePath(1024, 5);
7
+
8
+ expect(path.startsWith('M 1024,512 ')).toBe(true);
9
+ expect(path.endsWith('Z')).toBe(true);
10
+ });
11
+
12
+ it('draws one line segment per degree of the sweep', () => {
13
+ const path = generateSquirclePath(1024, 5);
14
+
15
+ expect(path.split('L').length - 1).toBe(361);
16
+ });
17
+ });
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
3
+ <circle cx="600" cy="600" r="400" fill="#FFFFFF"/>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
3
+ <path d="M600,200 A400,400 0 1,1 599,200 Z" fill="#FFFFFF"/>
4
+ </svg>
@@ -0,0 +1,38 @@
1
+ {
2
+ "fill": {
3
+ "automatic-gradient": "display-p3:0.37790,0.12750,0.64098,1.00000"
4
+ },
5
+ "groups": [
6
+ {
7
+ "layers": [
8
+ {
9
+ "glass": true,
10
+ "hidden": false,
11
+ "image-name": "glyph.svg",
12
+ "name": "glyph",
13
+ "position": {
14
+ "scale": 0.77,
15
+ "translation-in-points": [
16
+ 0,
17
+ 0
18
+ ]
19
+ }
20
+ }
21
+ ],
22
+ "shadow": {
23
+ "kind": "neutral",
24
+ "opacity": 0.5
25
+ },
26
+ "translucency": {
27
+ "enabled": true,
28
+ "value": 0.5
29
+ }
30
+ }
31
+ ],
32
+ "supported-platforms": {
33
+ "circles": [
34
+ "watchOS"
35
+ ],
36
+ "squares": "shared"
37
+ }
38
+ }