Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
- name: Install Node dependencies
run: npm ci

- name: Run JS tests
run: npm test

- name: Copy environment file
run: cp .env.example .env

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/public/build
/public/hot
/public/storage
/resources/icons
/storage/*.key
/storage/pail
/vendor
Expand Down
71 changes: 71 additions & 0 deletions bin/icons/colors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// D65 matrices, ported from regen-icons.sh's hex_to_display_p3 Python heredoc.
const SRGB_TO_XYZ = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.072175],
[0.0193339, 0.119192, 0.9503041],
];

const XYZ_TO_P3 = [
[2.4934969, -0.9313836, -0.4027108],
[-0.829489, 1.762664, 0.0236247],
[0.0358458, -0.0761724, 0.9568845],
];

// Empirical per-channel darkening observed in apple-touch-icon output vs the
// requested background color, ported from regen-icons.sh's DARKEN constant.
const APPLE_RENDER_DARKEN = [
0.6022727272727273, 0.4375, 0.7962962962962963,
];

function multiply(matrix, vector) {
return matrix.map((row) =>
row.reduce((sum, value, index) => sum + value * vector[index], 0),
);
}

function srgbToLinear(channel) {
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
}

export function hexToRgb(hex) {
const raw = hex.replace('#', '');

return [0, 2, 4].map((offset) => Number.parseInt(raw.slice(offset, offset + 2), 16));
}

export function rgbToHex([r, g, b]) {
return `#${[r, g, b]
.map((channel) => channel.toString(16).padStart(2, '0').toUpperCase())
.join('')}`;
}

export function hexToDisplayP3(hex) {
const linear = hexToRgb(hex).map((channel) => srgbToLinear(channel / 255));
const xyz = multiply(SRGB_TO_XYZ, linear);
const p3 = multiply(XYZ_TO_P3, xyz).map((channel) => Math.min(1, Math.max(0, channel)));

return `display-p3:${p3.map((channel) => channel.toFixed(5)).join(',')},1.00000`;
}

export function compensateForAppleRender(hex) {
const compensated = hexToRgb(hex).map((channel, index) =>
Math.min(255, Math.max(0, Math.round(channel / APPLE_RENDER_DARKEN[index]))),
);

return rgbToHex(compensated);
}

// Apple's icon tool stores gamma-encoded P3 components directly in the sRGB
// container without gamut conversion. This replicates that quirk so the
// background color used for icon rendering matches the old pipeline exactly.
export function p3StringToAppleRgb(p3Str) {
const match = p3Str.match(/display-p3:([\d.]+),([\d.]+),([\d.]+)/);

if (!match) {
throw new Error(`p3StringToAppleRgb: cannot parse "${p3Str}"`);
}

return [match[1], match[2], match[3]].map((v) => Math.round(Number(v) * 255));
}
40 changes: 40 additions & 0 deletions bin/icons/colors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { compensateForAppleRender, hexToDisplayP3, hexToRgb, p3StringToAppleRgb } from './colors.js';

describe('hexToRgb', () => {
it('parses a hex string into RGB channel values', () => {
expect(hexToRgb('#6A2AAC')).toEqual([106, 42, 172]);
});
});

describe('hexToDisplayP3', () => {
it('converts the brand purple to a Display P3 string', () => {
expect(hexToDisplayP3('#6A2AAC')).toBe(
'display-p3:0.12267,0.02717,0.37968,1.00000',
);
});

it('converts black to zeroed P3 components', () => {
expect(hexToDisplayP3('#000000')).toBe(
'display-p3:0.00000,0.00000,0.00000,1.00000',
);
});
});

describe('compensateForAppleRender', () => {
it('lightens the brand purple to counter Apple darkening', () => {
expect(compensateForAppleRender('#6A2AAC')).toBe('#B060D8');
});

it('leaves black and white unaffected', () => {
expect(compensateForAppleRender('#000000')).toBe('#000000');
expect(compensateForAppleRender('#FFFFFF')).toBe('#FFFFFF');
});
});

describe('p3StringToAppleRgb', () => {
it('treats P3 components as sRGB matching Apple icon tool quirk', () => {
// display-p3:0.37790,0.12750,0.64098 -> rgb(96, 33, 163) not rgb(176, 96, 216)
expect(p3StringToAppleRgb('display-p3:0.37790,0.12750,0.64098,1.00000')).toEqual([96, 33, 163]);
});
});
216 changes: 216 additions & 0 deletions bin/icons/generate-apple-touch-icon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* global Buffer */
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { compensateForAppleRender, hexToDisplayP3, p3StringToAppleRgb } from './colors.js';
import { buildCanvasSvg, extractGlyphMarkup } from './generate-web-icons.js';
import { generateSquirclePath } from './squircle.js';

const ICON_DIR = 'resources/branding/bloom.icon';
const JSON_PATH = path.join(ICON_DIR, 'icon.json');
const DEFAULT_OUTPUT_DIR = 'resources/icons';
const SIZE = 1024;

// Apple's "automatic-gradient" lightens the top of the icon by ~40 RGB units,
// reaching the base color at ~70% of the height and staying flat below that.
const GRADIENT_LIFT = 40;

async function fileExists(filePath) {
try {
await fs.access(filePath);

return true;
} catch {
return false;
}
}

async function syncIconJsonGradient(compensatedHex, write = true) {
const iconData = JSON.parse(await fs.readFile(JSON_PATH, 'utf-8'));

iconData.fill = { ...iconData.fill, 'automatic-gradient': hexToDisplayP3(compensatedHex) };

if (write) {
await fs.writeFile(JSON_PATH, `${JSON.stringify(iconData, null, 2)}\n`, 'utf-8');
}

return iconData;
}

function backgroundLayer(rgb) {
const [r, g, b] = rgb;
const baseColor = `rgb(${r}, ${g}, ${b})`;
const topColor = `rgb(${Math.min(255, r + GRADIENT_LIFT)}, ${Math.min(255, g + GRADIENT_LIFT)}, ${Math.min(255, b + GRADIENT_LIFT)})`;

const squircleMask = Buffer.from(`
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" />
</svg>
`);

const gradient = Buffer.from(`
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${topColor}" />
<stop offset="70%" style="stop-color:${baseColor}" />
<stop offset="100%" style="stop-color:${baseColor}" />
</linearGradient>
<rect width="${SIZE}" height="${SIZE}" fill="url(#grad)" />
</svg>
`);

return sharp(gradient).composite([{ input: squircleMask, blend: 'dest-in' }]).png().toBuffer();
}

async function glyphLayer(group, layer) {
const imagePath = path.join(ICON_DIR, 'Assets', layer['image-name']);

if (!(await fileExists(imagePath))) {
throw new Error(`glyphLayer: missing icon asset ${imagePath} referenced by icon.json`);
}

const originalSvg = await fs.readFile(imagePath, 'utf-8');
const pathMatch = originalSvg.match(/<path d="([^"]+)"/);

if (!pathMatch) {
throw new Error(`glyphLayer: no <path d="..."> found in ${imagePath}`);
}

const scale = layer.position?.scale || 1.0;
// Apple's icon JSON expresses scale as a "coverage" fraction; its renderer
// maps that to an effective layer size via a power curve — exponent ~0.35
// empirically matches Xcode's output across the observable scale range.
const renderedScale = scale ** 0.35;
const layerSize = Math.round(SIZE * renderedScale);
const layerOffset = Math.round((SIZE - layerSize) / 2);

// Apple's translucency is a frosted-glass blend, not simple fill-opacity.
// The interior petal pixels in the reference output match ~0.675 opacity for
// translucency=0.5; specular highlights then push bright edges toward white.
const translucency = group.translucency?.enabled ? (group.translucency.value ?? 0.5) : 1.0;
const layerOpacity = Math.min(1.0, 0.4 + translucency * 0.55);

const glassGlyphSvg = `
<svg width="${layerSize}" height="${layerSize}" viewBox="0 0 1200 1200">
<defs>
<filter id="liquidGlass" x="-15%" y="-15%" width="130%" height="130%">
<feGaussianBlur in="SourceAlpha" stdDeviation="14" result="glowBlur" />
<feFlood flood-color="white" flood-opacity="0.3" result="glowFill" />
<feComposite in="glowFill" in2="glowBlur" operator="in" result="outerGlow" />
<feGaussianBlur in="SourceAlpha" stdDeviation="16" result="bump" />
<feSpecularLighting in="bump" surfaceScale="6" specularConstant="3" specularExponent="25" lighting-color="white" result="spec">
<fePointLight x="-300" y="-500" z="900" />
</feSpecularLighting>
<feComposite in="spec" in2="SourceAlpha" operator="in" result="specLight" />
<feMerge>
<feMergeNode in="outerGlow" />
<feMergeNode in="SourceGraphic" />
<feMergeNode in="specLight" />
</feMerge>
</filter>
</defs>
<path d="${pathMatch[1]}" fill="white" fill-opacity="${layerOpacity}" filter="url(#liquidGlass)" />
</svg>
`;

const input = await sharp(Buffer.from(glassGlyphSvg)).png().toBuffer();

return { input, top: layerOffset, left: layerOffset };
}

// Apple's squircle has a ~20px bright specular highlight along all edges,
// clipped to the squircle boundary: feMorphology erode carves a border ring,
// then a Gaussian blur softens it inward.
async function edgeGlowLayer() {
const svg = `
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<filter id="edgeGlow" x="0%" y="0%" width="100%" height="100%">
<feMorphology in="SourceAlpha" operator="erode" radius="16" result="eroded" />
<feComposite in="SourceAlpha" in2="eroded" operator="arithmetic" k2="1" k3="-1" result="ring" />
<feGaussianBlur in="ring" stdDeviation="7" result="soft" />
<feFlood flood-color="white" flood-opacity="0.6" result="white" />
<feComposite in="white" in2="soft" operator="in" result="glow" />
<feComposite in="glow" in2="SourceAlpha" operator="in" />
</filter>
</defs>
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#edgeGlow)" />
</svg>
`;

return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
}

// Apple's top-left corner has a stronger, crisper highlight. surfaceScale=51
// compensates for librsvg normalising bump gradients by 255; the low z=80
// point light makes interior normals near-zero while the TL corner's outward
// normal aligns with the light, creating a highlight that fades at TR/BR.
async function cornerSpecularLayer() {
const svg = `
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<filter id="cornerSpec" x="0%" y="0%" width="100%" height="100%">
<feGaussianBlur in="SourceAlpha" stdDeviation="12" result="bump" />
<feSpecularLighting in="bump" surfaceScale="51" specularConstant="0.65" specularExponent="8" lighting-color="white" result="spec">
<fePointLight x="-100" y="-100" z="80" />
</feSpecularLighting>
<feComposite in="spec" in2="SourceAlpha" operator="in" />
</filter>
</defs>
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#cornerSpec)" />
</svg>
`;

return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
}

async function generateFlatIcon(config, outputDir = DEFAULT_OUTPUT_DIR) {
// No Apple Icon Composer bundle for this app: fall back to the brand glyph
// flat on the background color, skipping the squircle/gradient/specular treatment.
const glyphSource = await fs.readFile(config.glyph, 'utf-8');
const glyphMarkup = extractGlyphMarkup(glyphSource);
const svg = buildCanvasSvg(glyphMarkup, config.backgroundColor);

await fs.mkdir(outputDir, { recursive: true });
await sharp(Buffer.from(svg))
.resize(SIZE, SIZE)
.png()
.toFile(path.join(outputDir, 'apple-touch-icon.png'));
}

export async function generateFromIconFile(config, outputDir = DEFAULT_OUTPUT_DIR, { syncJson = true } = {}) {

const compensatedHex = compensateForAppleRender(config.backgroundColor);
const iconData = await syncIconJsonGradient(compensatedHex, syncJson);
// Replicate Apple's icon tool quirk: P3 components are stored in the sRGB
// container without gamut conversion, so we read them back the same way.
const rgb = p3StringToAppleRgb(iconData.fill['automatic-gradient']);

const composites = [{ input: await backgroundLayer(rgb), top: 0, left: 0 }];

for (const group of iconData.groups || []) {
for (const layer of group.layers || []) {
composites.push(await glyphLayer(group, layer));
}
}

composites.push(await edgeGlowLayer());
composites.push(await cornerSpecularLayer());

await fs.mkdir(outputDir, { recursive: true });
await sharp({
create: { width: SIZE, height: SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.composite(composites)
.toFile(path.join(outputDir, 'apple-touch-icon.png'));
}

export async function generateAppleTouchIcon(config, outputDir = DEFAULT_OUTPUT_DIR, { syncJson = true } = {}) {
if (await fileExists(ICON_DIR)) {
console.log(`Generating Apple Touch Icon from ${JSON_PATH}...`);
await generateFromIconFile(config, outputDir, { syncJson });
} else {
console.log(`Generating flat Apple Touch Icon with background color ${config.backgroundColor}...`);
await generateFlatIcon(config, outputDir);
}
}
Loading