#!/usr/bin/env node // Renders the Android adaptive-icon layers from the Icon Composer SVG sources.//// Icon Composer exports already contain a rounded-square silhouette, and Android masks// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a// full-bleed background layer (the artwork behind the wordmark) and a shared transparent// foreground that keeps the wordmark inside the safe zone. import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Console from "effect/Console";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import sharp from "sharp"; type IconVariant = "dev" | "nightly" | "prod"; // 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.const CANVAS = 432;// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp// guaranteed), so 0.48 leaves the letters at ~72% of the mask with room for the// launcher's own zoom effects.const WORDMARK_FRACTION = 0.48;// Icon Composer positions layers on a 1024pt canvas, with translation relative to center.const COMPOSER_CANVAS_PT = 1024;const SVG_DENSITY = 300;const OUTPUT_DIRECTORY = "apps/mobile/assets"; export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()( "AndroidIconRenderError", { layer: Schema.String, cause: Schema.Defect() },) {} const wordmarkTransform = () => { const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width; const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale; const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale; return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;}; const canvasSvg = (inner: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`; // The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android// applies its own mask, so the layer must bleed to the canvas edge.const fullBleed = (svg: string) => svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>'); const rasterize = (layer: string, svg: string, width: number, height = width) => Effect.tryPromise({ try: () => sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(), catch: (cause) => new AndroidIconRenderError({ layer, cause }), }); const composite = ( layer: string, base: Buffer, overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,) => Effect.tryPromise({ try: () => sharp(base) .composite([...overlays]) .png() .toBuffer(), catch: (cause) => new AndroidIconRenderError({ layer, cause }), }); const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* ( repositoryRoot: string, variant: IconVariant, file: string,) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* fs.readFileString( path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file), );}); const renderForeground = Effect.fn("androidIcons.renderForeground")(function* ( repositoryRoot: string,) { const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg"); const paths = text.match(/<path[^>]*\/>/g) ?? []; return yield* rasterize( "foreground", canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`), CANVAS, );}); const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")( function* (repositoryRoot: string) { // The annotation layer shares the wordmark's coordinate space, so it is scaled and // centered the same way to keep the dimension lines around the letters. const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg"); const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? ""; const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, ""); const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg"); const background = yield* rasterize("dev-background", fullBleed(paper), CANVAS); const overlay = yield* rasterize( "dev-annotations", canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`), CANVAS, ); return yield* composite("dev-background", background, [{ input: overlay }]); },); const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* ( repositoryRoot: string,) { // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is // dropped because Icon Composer ignores it and it smears at this raster size. const clouds = [ { file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] }, { file: "cloud-upper-right.svg", scale: 15, translation: [387.9605131881942, -134.30064713259117], }, ] as const; const k = CANVAS / COMPOSER_CANVAS_PT; const overlays = yield* Effect.forEach(clouds, (cloud) => Effect.gen(function* () { const width = Math.round(64 * cloud.scale * k); const height = Math.round(32 * cloud.scale * k); const left = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[0]) * k - width / 2); const top = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[1]) * k - height / 2); const source = yield* readLayerSource(repositoryRoot, "nightly", cloud.file); const png = yield* rasterize( cloud.file, source.replace(/ filter="url\(#soft\)"/, ""), width, height, ); const x0 = Math.max(0, left); const y0 = Math.max(0, top); const x1 = Math.min(CANVAS, left + width); const y1 = Math.min(CANVAS, top + height); const clipped = yield* Effect.tryPromise({ try: () => sharp(png) .extract({ left: x0 - left, top: y0 - top, width: x1 - x0, height: y1 - y0 }) .png() .toBuffer(), catch: (cause) => new AndroidIconRenderError({ layer: cloud.file, cause }), }); return { input: clipped, left: x0, top: y0 }; }), ); const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg"); const background = yield* rasterize("nightly-background", fullBleed(sky), CANVAS); return yield* composite("nightly-background", background, overlays);}); const exportAndroidIcons = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const repositoryRoot = path.resolve(import.meta.dirname, ".."); const outputs = [ ["android-icon-foreground.png", yield* renderForeground(repositoryRoot)], ["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)], ["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)], ] as const; for (const [name, contents] of outputs) { yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents); yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`); }}); if (import.meta.main) { exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);}
#!/usr/bin/env node
// Renders the Android adaptive-icon layers from the Icon Composer SVG sources.
//
// Icon Composer exports already contain a rounded-square silhouette, and Android masks
// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces
// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a
// full-bleed background layer (the artwork behind the wordmark) and a shared transparent
// foreground that keeps the wordmark inside the safe zone.
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import sharp from "sharp";
type IconVariant = "dev" | "nightly" | "prod";
// 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.
const CANVAS = 432;
// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.
const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp
// guaranteed), so 0.48 leaves the letters at ~72% of the mask with room for the
// launcher's own zoom effects.
const WORDMARK_FRACTION = 0.48;
// Icon Composer positions layers on a 1024pt canvas, with translation relative to center.
const COMPOSER_CANVAS_PT = 1024;
const SVG_DENSITY = 300;
const OUTPUT_DIRECTORY = "apps/mobile/assets";
export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(
"AndroidIconRenderError",
{ layer: Schema.String, cause: Schema.Defect() },
) {}
const wordmarkTransform = () => {
const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width;
const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale;
const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale;
return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
};
const canvasSvg = (inner: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`;
// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android
// applies its own mask, so the layer must bleed to the canvas edge.
const fullBleed = (svg: string) =>
svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>');
const rasterize = (layer: string, svg: string, width: number, height = width) =>
Effect.tryPromise({
try: () =>
sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer, cause }),
});
const composite = (
layer: string,
base: Buffer,
overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,
) =>
Effect.tryPromise({
try: () =>
sharp(base)
.composite([...overlays])
.png()
.toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer, cause }),
});
const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
repositoryRoot: string,
variant: IconVariant,
file: string,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return yield* fs.readFileString(
path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file),
);
});
const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
repositoryRoot: string,
) {
const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
const paths = text.match(/<path[^>]*\/>/g) ?? [];
return yield* rasterize(
"foreground",
canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`),
CANVAS,
);
});
const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
function* (repositoryRoot: string) {
// The annotation layer shares the wordmark's coordinate space, so it is scaled and
// centered the same way to keep the dimension lines around the letters.
const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
const background = yield* rasterize("dev-background", fullBleed(paper), CANVAS);
const overlay = yield* rasterize(
"dev-annotations",
canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`),
CANVAS,
);
return yield* composite("dev-background", background, [{ input: overlay }]);
},
);
const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
repositoryRoot: string,
) {
// Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is
// dropped because Icon Composer ignores it and it smears at this raster size.
const clouds = [
{ file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] },
{
file: "cloud-upper-right.svg",
scale: 15,
translation: [387.9605131881942, -134.30064713259117],
},
] as const;
const k = CANVAS / COMPOSER_CANVAS_PT;
const overlays = yield* Effect.forEach(clouds, (cloud) =>
Effect.gen(function* () {
const width = Math.round(64 * cloud.scale * k);
const height = Math.round(32 * cloud.scale * k);
const left = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[0]) * k - width / 2);
const top = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[1]) * k - height / 2);
const source = yield* readLayerSource(repositoryRoot, "nightly", cloud.file);
const png = yield* rasterize(
cloud.file,
source.replace(/ filter="url\(#soft\)"/, ""),
width,
height,
);
const x0 = Math.max(0, left);
const y0 = Math.max(0, top);
const x1 = Math.min(CANVAS, left + width);
const y1 = Math.min(CANVAS, top + height);
const clipped = yield* Effect.tryPromise({
try: () =>
sharp(png)
.extract({ left: x0 - left, top: y0 - top, width: x1 - x0, height: y1 - y0 })
.png()
.toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer: cloud.file, cause }),
});
return { input: clipped, left: x0, top: y0 };
}),
);
const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
const background = yield* rasterize("nightly-background", fullBleed(sky), CANVAS);
return yield* composite("nightly-background", background, overlays);
});
const exportAndroidIcons = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const repositoryRoot = path.resolve(import.meta.dirname, "..");
const outputs = [
["android-icon-foreground.png", yield* renderForeground(repositoryRoot)],
["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)],
["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)],
] as const;
for (const [name, contents] of outputs) {
yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);
yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`);
}
});
if (import.meta.main) {
exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
}