authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 15:34:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 15:34:33-07:00
logc0cad74bfaddf153c3adaaf4f78af2be9260c4c1
tree16117d97826252c0c67ffb7415b44285d868e838
parent12f5604442564d3e8c1cb23135b27b37ab4af1cf
signature Signed by PGP key B5690EEEBB952194

fix(mobile): restore brand artwork in the Android adaptive icon (#10598)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 211 insertions(+), 22 deletions(-)

apps/mobile/app.config.ts+12-3
...@@ -18,8 +18,8 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?...@@ -18,8 +18,8 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?
18const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;18const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
1919
20const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;20const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
21// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive21// Android layers are rendered by scripts/export-android-icons.ts from the Icon Composer sources.
22// foreground makes Android draw an icon shape inside the launcher's mask.22// The wordmark sits inside the adaptive safe zone; the variant artwork is a full-bleed background.
23const androidAdaptiveForeground = "./assets/android-icon-foreground.png";23const androidAdaptiveForeground = "./assets/android-icon-foreground.png";
2424
25if (25if (
...@@ -37,7 +37,8 @@ const DEVELOPMENT_ASSETS = {...@@ -37,7 +37,8 @@ const DEVELOPMENT_ASSETS = {
37 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),37 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
38 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),38 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
39 androidAdaptiveForeground,39 androidAdaptiveForeground,
40 androidAdaptiveBackgroundColor: "#00639B",40 androidAdaptiveBackgroundColor: "#347FF8",
41 androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",
41 androidMonochromeIcon: "./assets/android-icon-mark.png",42 androidMonochromeIcon: "./assets/android-icon-mark.png",
42 androidNotificationIcon: "./assets/android-notification-icon.png",43 androidNotificationIcon: "./assets/android-notification-icon.png",
43 androidNotificationColor: "#00639B",44 androidNotificationColor: "#00639B",
...@@ -49,6 +50,7 @@ const PREVIEW_ASSETS = {...@@ -49,6 +50,7 @@ const PREVIEW_ASSETS = {
49 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),50 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
50 androidAdaptiveForeground,51 androidAdaptiveForeground,
51 androidAdaptiveBackgroundColor: "#111533",52 androidAdaptiveBackgroundColor: "#111533",
53 androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",
52 androidMonochromeIcon: "./assets/android-icon-mark.png",54 androidMonochromeIcon: "./assets/android-icon-mark.png",
53 androidNotificationIcon: "./assets/android-notification-icon.png",55 androidNotificationIcon: "./assets/android-notification-icon.png",
54 androidNotificationColor: "#7565C7",56 androidNotificationColor: "#7565C7",
...@@ -60,6 +62,7 @@ const RELEASE_ASSETS = {...@@ -60,6 +62,7 @@ const RELEASE_ASSETS = {
60 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),62 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
61 androidAdaptiveForeground,63 androidAdaptiveForeground,
62 androidAdaptiveBackgroundColor: "#000000",64 androidAdaptiveBackgroundColor: "#000000",
65 androidAdaptiveBackgroundImage: undefined,
63 androidMonochromeIcon: "./assets/android-icon-mark.png",66 androidMonochromeIcon: "./assets/android-icon-mark.png",
64 androidNotificationIcon: "./assets/android-notification-icon.png",67 androidNotificationIcon: "./assets/android-notification-icon.png",
65 androidNotificationColor: "#FFFFFF",68 androidNotificationColor: "#FFFFFF",
...@@ -230,6 +233,9 @@ const config: ExpoConfig = {...@@ -230,6 +233,9 @@ const config: ExpoConfig = {
230 package: variant.androidPackage,233 package: variant.androidPackage,
231 adaptiveIcon: {234 adaptiveIcon: {
232 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,235 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
236 ...(variant.assets.androidAdaptiveBackgroundImage
237 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
238 : {}),
233 foregroundImage: variant.assets.androidAdaptiveForeground,239 foregroundImage: variant.assets.androidAdaptiveForeground,
234 monochromeImage: variant.assets.androidMonochromeIcon,240 monochromeImage: variant.assets.androidMonochromeIcon,
235 },241 },
...@@ -293,6 +299,9 @@ const config: ExpoConfig = {...@@ -293,6 +299,9 @@ const config: ExpoConfig = {
293 shortcut_icon: {299 shortcut_icon: {
294 foregroundImage: variant.assets.androidAdaptiveForeground,300 foregroundImage: variant.assets.androidAdaptiveForeground,
295 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,301 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
302 ...(variant.assets.androidAdaptiveBackgroundImage
303 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
304 : {}),
296 },305 },
297 },306 },
298 },307 },
apps/mobile/assets/android-icon-background-dev.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-background-dev.png differ
apps/mobile/assets/android-icon-background-nightly.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-background-nightly.png differ
apps/mobile/assets/android-icon-foreground.png
Binary files a/apps/mobile/assets/android-icon-foreground.png and b/apps/mobile/assets/android-icon-foreground.png differ
apps/mobile/assets/android-icon-foreground.svg deleted-8
...@@ -1,8 +0,0 @@
1<svg width="432" height="432" viewBox="0 0 432 432" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <g transform="translate(116.3 155.8) scale(2.6)">
3 <path
4 d="M14.56 45.5V8.58H0V0H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V0H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z"
5 fill="#FFFFFF"
6 />
7 </g>
8</svg>
assets/README.md+11-10
...@@ -49,16 +49,17 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8...@@ -49,16 +49,17 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8
4949
50Do not edit the generated PNG or ICO files directly.50Do not edit the generated PNG or ICO files directly.
5151
52## Android adaptive foreground52## Android adaptive icon layers
5353
54`apps/mobile/assets/android-icon-foreground.svg` is the source of truth for the foreground used by54Android masks the central 72dp of a 108dp adaptive canvas, so the Icon Composer exports cannot be
55the normal Android adaptive launcher icon. Export its paired PNG after changing it:55used directly: their rounded-square silhouette gets framed again and the wordmark is cropped. The
56Android layers are instead rendered from the same Icon Composer SVG sources by
57`vp run icons:export:android`:
5658
57```sh59- `apps/mobile/assets/android-icon-foreground.png`: the shared transparent wordmark, sized to stay
58rsvg-convert -w 432 -h 432 \60 inside the safe zone
59 -o apps/mobile/assets/android-icon-foreground.png \61- `apps/mobile/assets/android-icon-background-dev.png` and `-nightly.png`: full-bleed variant
60 apps/mobile/assets/android-icon-foreground.svg62 artwork (blueprint grid and annotations; night sky and clouds). Production uses a solid color.
61```
6263
63The foreground must remain transparent and keep the T3 mark inside Android's adaptive-icon safe64Rerun the export after changing a layer SVG. `android-icon-mark.png` remains a flat silhouette for
64zone. `android-icon-mark.png` remains a flat silhouette for Android's monochrome themed icon.65Android's monochrome themed icon.
package.json+1
...@@ -18,6 +18,7 @@...@@ -18,6 +18,7 @@
18 "screenshots:mobile": "node scripts/mobile-showcase.ts",18 "screenshots:mobile": "node scripts/mobile-showcase.ts",
19 "icons:export": "node scripts/export-brand-icons.ts",19 "icons:export": "node scripts/export-brand-icons.ts",
20 "icons:check": "node scripts/export-brand-icons.ts --check",20 "icons:check": "node scripts/export-brand-icons.ts --check",
21 "icons:export:android": "node scripts/export-android-icons.ts",
21 "build": "vp run --filter './apps/*' build",22 "build": "vp run --filter './apps/*' build",
22 "build:marketing": "vp run --filter @t3tools/marketing build",23 "build:marketing": "vp run --filter @t3tools/marketing build",
23 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",24 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
pnpm-lock.yaml+3
...@@ -990,6 +990,9 @@ importers:...@@ -990,6 +990,9 @@ importers:
990 pngjs:990 pngjs:
991 specifier: 7.0.0991 specifier: 7.0.0
992 version: 7.0.0992 version: 7.0.0
993 sharp:
994 specifier: 0.34.5
995 version: 0.34.5
993 devDependencies:996 devDependencies:
994 '@effect/vitest':997 '@effect/vitest':
995 specifier: 4.0.0-beta.103998 specifier: 4.0.0-beta.103
scripts/export-android-icons.ts created+182
...@@ -0,0 +1,182 @@
1#!/usr/bin/env node
2
3// Renders the Android adaptive-icon layers from the Icon Composer SVG sources.
4//
5// Icon Composer exports already contain a rounded-square silhouette, and Android masks
6// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces
7// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a
8// full-bleed background layer (the artwork behind the wordmark) and a shared transparent
9// foreground that keeps the wordmark inside the safe zone.
10
11import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import * as Console from "effect/Console";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import sharp from "sharp";
19
20type IconVariant = "dev" | "nightly" | "prod";
21
22// 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.
23const CANVAS = 432;
24// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.
25const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
26// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp
27// guaranteed), so 0.48 leaves the letters at ~72% of the mask with room for the
28// launcher's own zoom effects.
29const WORDMARK_FRACTION = 0.48;
30// Icon Composer positions layers on a 1024pt canvas, with translation relative to center.
31const COMPOSER_CANVAS_PT = 1024;
32const SVG_DENSITY = 300;
33const OUTPUT_DIRECTORY = "apps/mobile/assets";
34
35export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(
36 "AndroidIconRenderError",
37 { layer: Schema.String, cause: Schema.Defect() },
38) {}
39
40const wordmarkTransform = () => {
41 const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width;
42 const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale;
43 const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale;
44 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
45};
46
47const canvasSvg = (inner: string) =>
48 `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`;
49
50// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android
51// applies its own mask, so the layer must bleed to the canvas edge.
52const fullBleed = (svg: string) =>
53 svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>');
54
55const rasterize = (layer: string, svg: string, width: number, height = width) =>
56 Effect.tryPromise({
57 try: () =>
58 sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(),
59 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
60 });
61
62const composite = (
63 layer: string,
64 base: Buffer,
65 overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,
66) =>
67 Effect.tryPromise({
68 try: () =>
69 sharp(base)
70 .composite([...overlays])
71 .png()
72 .toBuffer(),
73 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
74 });
75
76const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
77 repositoryRoot: string,
78 variant: IconVariant,
79 file: string,
80) {
81 const fs = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 return yield* fs.readFileString(
84 path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file),
85 );
86});
87
88const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
89 repositoryRoot: string,
90) {
91 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
92 const paths = text.match(/<path[^>]*\/>/g) ?? [];
93 return yield* rasterize(
94 "foreground",
95 canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`),
96 CANVAS,
97 );
98});
99
100const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
101 function* (repositoryRoot: string) {
102 // The annotation layer shares the wordmark's coordinate space, so it is scaled and
103 // centered the same way to keep the dimension lines around the letters.
104 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
105 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
106 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
107 const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
108 const background = yield* rasterize("dev-background", fullBleed(paper), CANVAS);
109 const overlay = yield* rasterize(
110 "dev-annotations",
111 canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`),
112 CANVAS,
113 );
114 return yield* composite("dev-background", background, [{ input: overlay }]);
115 },
116);
117
118const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
119 repositoryRoot: string,
120) {
121 // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is
122 // dropped because Icon Composer ignores it and it smears at this raster size.
123 const clouds = [
124 { file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] },
125 {
126 file: "cloud-upper-right.svg",
127 scale: 15,
128 translation: [387.9605131881942, -134.30064713259117],
129 },
130 ] as const;
131 const k = CANVAS / COMPOSER_CANVAS_PT;
132 const overlays = yield* Effect.forEach(clouds, (cloud) =>
133 Effect.gen(function* () {
134 const width = Math.round(64 * cloud.scale * k);
135 const height = Math.round(32 * cloud.scale * k);
136 const left = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[0]) * k - width / 2);
137 const top = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[1]) * k - height / 2);
138 const source = yield* readLayerSource(repositoryRoot, "nightly", cloud.file);
139 const png = yield* rasterize(
140 cloud.file,
141 source.replace(/ filter="url\(#soft\)"/, ""),
142 width,
143 height,
144 );
145 const x0 = Math.max(0, left);
146 const y0 = Math.max(0, top);
147 const x1 = Math.min(CANVAS, left + width);
148 const y1 = Math.min(CANVAS, top + height);
149 const clipped = yield* Effect.tryPromise({
150 try: () =>
151 sharp(png)
152 .extract({ left: x0 - left, top: y0 - top, width: x1 - x0, height: y1 - y0 })
153 .png()
154 .toBuffer(),
155 catch: (cause) => new AndroidIconRenderError({ layer: cloud.file, cause }),
156 });
157 return { input: clipped, left: x0, top: y0 };
158 }),
159 );
160 const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
161 const background = yield* rasterize("nightly-background", fullBleed(sky), CANVAS);
162 return yield* composite("nightly-background", background, overlays);
163});
164
165const exportAndroidIcons = Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const repositoryRoot = path.resolve(import.meta.dirname, "..");
169 const outputs = [
170 ["android-icon-foreground.png", yield* renderForeground(repositoryRoot)],
171 ["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)],
172 ["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)],
173 ] as const;
174 for (const [name, contents] of outputs) {
175 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);
176 yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`);
177 }
178});
179
180if (import.meta.main) {
181 exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
182}
scripts/package.json+2-1
...@@ -13,7 +13,8 @@...@@ -13,7 +13,8 @@
13 "@t3tools/shared": "workspace:*",13 "@t3tools/shared": "workspace:*",
14 "@t3tools/tailscale": "workspace:*",14 "@t3tools/tailscale": "workspace:*",
15 "effect": "catalog:",15 "effect": "catalog:",
16 "pngjs": "7.0.0"16 "pngjs": "7.0.0",
17 "sharp": "0.34.5"
17 },18 },
18 "devDependencies": {19 "devDependencies": {
19 "@effect/vitest": "catalog:",20 "@effect/vitest": "catalog:",