diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index c27be2e357f..4a0c761f2c6 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -1,5 +1,6 @@
import type { ExpoConfig } from "expo/config";
+import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts";
import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
type AppVariant = "development" | "preview" | "production";
@@ -13,6 +14,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
+const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
+
if (
isIosPersonalTeamBuild &&
(!personalTeamBundleIdentifier ||
@@ -24,20 +27,31 @@ if (
}
const DEVELOPMENT_ASSETS = {
- appIcon: "./assets/splash-icon-dev.png",
- iosIcon: "./assets/icon-composer-dev.icon",
- splashIcon: "./assets/splash-icon-dev.png",
- androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png",
+ appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
+ iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
+ splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
+ androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng),
androidAdaptiveBackgroundColor: "#00639B",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
androidNotificationColor: "#00639B",
} as const;
+const PREVIEW_ASSETS = {
+ appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
+ iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject),
+ splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
+ androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng),
+ androidAdaptiveBackgroundColor: "#111533",
+ androidMonochromeIcon: "./assets/android-icon-mark.png",
+ androidNotificationIcon: "./assets/android-notification-icon.png",
+ androidNotificationColor: "#7565C7",
+} as const;
+
const RELEASE_ASSETS = {
- appIcon: "./assets/splash-icon-prod.png",
- iosIcon: "./assets/icon-composer-prod.icon",
- splashIcon: "./assets/splash-icon-prod.png",
+ appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
+ iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject),
+ splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
androidAdaptiveForeground: "./assets/android-icon-mark.png",
androidAdaptiveBackgroundColor: "#000000",
androidMonochromeIcon: "./assets/android-icon-mark.png",
@@ -60,7 +74,7 @@ const VARIANT_CONFIG = {
iosBundleIdentifier: "com.t3tools.t3code.preview",
androidPackage: "com.t3tools.t3code.preview",
relyingParty: "clerk.t3.codes",
- assets: RELEASE_ASSETS,
+ assets: PREVIEW_ASSETS,
},
production: {
appName: "T3 Code",
diff --git a/apps/mobile/assets/android-icon-dev-foreground.png b/apps/mobile/assets/android-icon-dev-foreground.png
deleted file mode 100644
index b33d7978d72..00000000000
Binary files a/apps/mobile/assets/android-icon-dev-foreground.png and /dev/null differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg
deleted file mode 100644
index d98c41a4e3e..00000000000
Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png
deleted file mode 100644
index de5a82d8c49..00000000000
Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ
diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg
deleted file mode 100644
index b12706fdfc2..00000000000
--- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png
deleted file mode 100644
index b33d6f337b0..00000000000
Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ
diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png
deleted file mode 100644
index 2ff311ce786..00000000000
Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ
diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx
index 4d1ac01b1ff..c8f1d438551 100644
--- a/apps/mobile/src/components/BrandMark.tsx
+++ b/apps/mobile/src/components/BrandMark.tsx
@@ -1,13 +1,23 @@
-import { Image, View } from "react-native";
+import Constants from "expo-constants";
+import { Image } from "expo-image";
+import { View } from "react-native";
import { AppText as Text } from "./AppText";
-const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
+const appVariant = Constants.expoConfig?.extra?.appVariant;
+const BRAND_MARK_SOURCE =
+ appVariant === "development"
+ ? require("../../../../assets/dev/blueprint-ios-1024.png")
+ : appVariant === "preview"
+ ? require("../../../../assets/nightly/nightly-ios-1024.png")
+ : require("../../../../assets/prod/black-ios-1024.png");
+const DEFAULT_STAGE_LABEL =
+ appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
const compact = props.compact ?? false;
const iconSize = compact ? 32 : 44;
- const stageLabel = props.stageLabel ?? "Alpha";
+ const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL;
return (
diff --git a/assets/README.md b/assets/README.md
new file mode 100644
index 00000000000..8a7e3c5f20a
--- /dev/null
+++ b/assets/README.md
@@ -0,0 +1,50 @@
+# Brand icons
+
+The three Icon Composer projects are the source of truth for full application icons:
+
+- `dev/app-icon.icon`
+- `nightly/app-icon.icon`
+- `prod/app-icon.icon`
+
+Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement.
+
+Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. Run `vp run icons:check` to verify that those generated assets match their sources without changing files.
+
+Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery.
+
+## macOS exports
+
+Icon Composer's command-line exporter does not expose the `macOS pre-Tahoe` preset. A plain command-line `macOS` export is full bleed and is not suitable for the desktop app, so the export script intentionally leaves the tracked macOS PNGs unchanged and prints a reminder after every run.
+
+After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings:
+
+- Platform: `macOS pre-Tahoe`
+- Appearance: `Default`
+- Size: `1024pt`
+- Scale: `1×`
+
+Save the three exports to:
+
+- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png`
+- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png`
+- `prod/app-icon.icon` -> `prod/black-macos-1024.png`
+
+The result must be a 1024×1024 PNG with the classic macOS safe area: the opaque icon body is 824×824, inset 100 pixels on every side, with only the native Icon Composer shadow extending into the surrounding transparent canvas.
+
+To have Codex perform the native exports, paste this prompt into a task opened at the repository root:
+
+```text
+Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.
+
+For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:
+
+- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png
+- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png
+- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png
+
+Do not resize, composite, or otherwise post-process the exported PNGs.
+
+Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.
+```
+
+Do not edit the generated PNG or ICO files directly.
diff --git a/assets/dev/app-icon.icon/Assets/annotations.svg b/assets/dev/app-icon.icon/Assets/annotations.svg
new file mode 100644
index 00000000000..dffde33559e
--- /dev/null
+++ b/assets/dev/app-icon.icon/Assets/annotations.svg
@@ -0,0 +1,37 @@
+
diff --git a/assets/dev/app-icon.icon/Assets/background.svg b/assets/dev/app-icon.icon/Assets/background.svg
new file mode 100644
index 00000000000..db5c319d607
--- /dev/null
+++ b/assets/dev/app-icon.icon/Assets/background.svg
@@ -0,0 +1,51 @@
+
diff --git a/assets/dev/app-icon.icon/Assets/text.svg b/assets/dev/app-icon.icon/Assets/text.svg
new file mode 100644
index 00000000000..0b1825f2ef4
--- /dev/null
+++ b/assets/dev/app-icon.icon/Assets/text.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/assets/dev/app-icon.icon/icon.json
similarity index 53%
rename from apps/mobile/assets/icon-composer-dev.icon/icon.json
rename to assets/dev/app-icon.icon/icon.json
index fd3fb6819a9..ff7d43d7811 100644
--- a/apps/mobile/assets/icon-composer-dev.icon/icon.json
+++ b/assets/dev/app-icon.icon/icon.json
@@ -6,35 +6,40 @@
{
"layers": [
{
- "hidden": false,
- "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png",
- "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr",
+ "glass": true,
+ "image-name": "text.svg",
+ "name": "Text",
"position": {
- "scale": 1.1,
+ "scale": 8.5,
"translation-in-points": [0, 0]
}
},
{
- "image-name": "T3.svg",
- "name": "T3",
+ "glass": false,
+ "image-name": "annotations.svg",
+ "name": "Annotations",
"position": {
- "scale": 10,
+ "scale": 8.5,
"translation-in-points": [0, 0]
}
},
{
- "hidden": true,
- "image-name": "Texturelabs_Paper_381XL.jpg",
- "name": "Texturelabs_Paper_381XL"
+ "glass": true,
+ "image-name": "background.svg",
+ "name": "Background",
+ "position": {
+ "scale": 8.1,
+ "translation-in-points": [0, 0]
+ }
}
],
"shadow": {
"kind": "neutral",
- "opacity": 0.5
+ "opacity": 0.6
},
"translucency": {
"enabled": true,
- "value": 0.5
+ "value": 0.2
}
}
],
diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg
deleted file mode 100644
index b12706fdfc2..00000000000
--- a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg
deleted file mode 100644
index d98c41a4e3e..00000000000
Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ
diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png
deleted file mode 100644
index fa7700d01c7..00000000000
Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ
diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json
deleted file mode 100644
index 1c15123dab4..00000000000
--- a/assets/dev/blueprint-icon-composer.icon/icon.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "fill": {
- "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
- },
- "groups": [
- {
- "layers": [
- {
- "blend-mode": "normal",
- "fill": "automatic",
- "glass": true,
- "hidden": false,
- "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png",
- "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871",
- "position": {
- "scale": 1.05,
- "translation-in-points": [0, 0]
- }
- },
- {
- "image-name": "T3.svg",
- "name": "T3",
- "position": {
- "scale": 10,
- "translation-in-points": [0, 0]
- }
- },
- {
- "hidden": false,
- "image-name": "Texturelabs_Paper_381XL.jpg",
- "name": "Texturelabs_Paper_381XL"
- }
- ],
- "shadow": {
- "kind": "neutral",
- "opacity": 0.5
- },
- "translucency": {
- "enabled": true,
- "value": 0.5
- }
- }
- ],
- "supported-platforms": {
- "squares": ["iOS", "macOS"]
- }
-}
diff --git a/assets/dev/blueprint-ios-1024.png b/assets/dev/blueprint-ios-1024.png
index 51b764ac72a..a53c8d19aa8 100644
Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ
diff --git a/assets/dev/blueprint-macos-1024.png b/assets/dev/blueprint-macos-1024.png
index 7c165fdb851..68a567058be 100644
Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ
diff --git a/assets/dev/blueprint-universal-1024.png b/assets/dev/blueprint-universal-1024.png
index 51b764ac72a..a53c8d19aa8 100644
Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ
diff --git a/assets/dev/blueprint-web-apple-touch-180.png b/assets/dev/blueprint-web-apple-touch-180.png
index 4a135d22fdb..3eed25ea6b7 100644
Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ
diff --git a/assets/dev/blueprint-web-favicon-16x16.png b/assets/dev/blueprint-web-favicon-16x16.png
index 56e0e837131..a3431b8c6df 100644
Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ
diff --git a/assets/dev/blueprint-web-favicon-32x32.png b/assets/dev/blueprint-web-favicon-32x32.png
index 9e9f31ea989..862f7629971 100644
Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ
diff --git a/assets/dev/blueprint-web-favicon.ico b/assets/dev/blueprint-web-favicon.ico
index e0ee3e1408f..750da22602e 100644
Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ
diff --git a/assets/dev/blueprint-windows.ico b/assets/dev/blueprint-windows.ico
index e0ee3e1408f..750da22602e 100644
Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ
diff --git a/assets/nightly/app-icon.icon/Assets/background.svg b/assets/nightly/app-icon.icon/Assets/background.svg
new file mode 100644
index 00000000000..1ceca91c023
--- /dev/null
+++ b/assets/nightly/app-icon.icon/Assets/background.svg
@@ -0,0 +1,43 @@
+
diff --git a/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg
new file mode 100644
index 00000000000..d43478860f4
--- /dev/null
+++ b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg
new file mode 100644
index 00000000000..7b094441db1
--- /dev/null
+++ b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/nightly/app-icon.icon/Assets/text.svg b/assets/nightly/app-icon.icon/Assets/text.svg
new file mode 100644
index 00000000000..0b1825f2ef4
--- /dev/null
+++ b/assets/nightly/app-icon.icon/Assets/text.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/nightly/app-icon.icon/icon.json b/assets/nightly/app-icon.icon/icon.json
new file mode 100644
index 00000000000..eb48d26b4a5
--- /dev/null
+++ b/assets/nightly/app-icon.icon/icon.json
@@ -0,0 +1,94 @@
+{
+ "fill": {
+ "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000"
+ },
+ "groups": [
+ {
+ "layers": [
+ {
+ "glass": true,
+ "image-name": "text.svg",
+ "name": "Text",
+ "position": {
+ "scale": 8.5,
+ "translation-in-points": [0, 0]
+ }
+ },
+ {
+ "glass": false,
+ "image-name": "cloud-upper-right.svg",
+ "name": "Cloud Upper Right",
+ "opacity": 1,
+ "position": {
+ "scale": 15,
+ "translation-in-points": [387.9605131881942, -134.30064713259117]
+ }
+ },
+ {
+ "glass": false,
+ "image-name": "cloud-lower-left.svg",
+ "name": "Cloud Lower Left",
+ "position": {
+ "scale": 25,
+ "translation-in-points": [-309.63750000000005, 268.66077693836917]
+ }
+ },
+ {
+ "glass": true,
+ "hidden": false,
+ "image-name": "background.svg",
+ "name": "Background",
+ "position": {
+ "scale": 8.1,
+ "translation-in-points": [0, -2.171875]
+ }
+ }
+ ],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.6
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.2
+ }
+ },
+ {
+ "layers": [],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.6
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.2
+ }
+ },
+ {
+ "layers": [],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.6
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.2
+ }
+ },
+ {
+ "layers": [],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.6
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.2
+ }
+ }
+ ],
+ "supported-platforms": {
+ "circles": ["watchOS"],
+ "squares": "shared"
+ }
+}
diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png
deleted file mode 100644
index b33d6f337b0..00000000000
Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ
diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png
deleted file mode 100644
index 8dba03e01fe..00000000000
Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ
diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png
deleted file mode 100644
index b33d6f337b0..00000000000
Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ
diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png
deleted file mode 100644
index e0e1b9659b8..00000000000
Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ
diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png
deleted file mode 100644
index 673d8459998..00000000000
Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ
diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png
deleted file mode 100644
index 25bcc95d4aa..00000000000
Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ
diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico
deleted file mode 100644
index 36975b99782..00000000000
Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ
diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico
deleted file mode 100644
index 36975b99782..00000000000
Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ
diff --git a/assets/nightly/nightly-ios-1024.png b/assets/nightly/nightly-ios-1024.png
new file mode 100644
index 00000000000..42ce5589e9c
Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ
diff --git a/assets/nightly/nightly-macos-1024.png b/assets/nightly/nightly-macos-1024.png
new file mode 100644
index 00000000000..1beacd9f34a
Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ
diff --git a/assets/nightly/nightly-universal-1024.png b/assets/nightly/nightly-universal-1024.png
new file mode 100644
index 00000000000..42ce5589e9c
Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ
diff --git a/assets/nightly/nightly-web-apple-touch-180.png b/assets/nightly/nightly-web-apple-touch-180.png
new file mode 100644
index 00000000000..f09a169458a
Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ
diff --git a/assets/nightly/nightly-web-favicon-16x16.png b/assets/nightly/nightly-web-favicon-16x16.png
new file mode 100644
index 00000000000..20208f5f369
Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ
diff --git a/assets/nightly/nightly-web-favicon-32x32.png b/assets/nightly/nightly-web-favicon-32x32.png
new file mode 100644
index 00000000000..b9008d518bc
Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ
diff --git a/assets/nightly/nightly-web-favicon.ico b/assets/nightly/nightly-web-favicon.ico
new file mode 100644
index 00000000000..b6a0b43b93d
Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ
diff --git a/assets/nightly/nightly-windows.ico b/assets/nightly/nightly-windows.ico
new file mode 100644
index 00000000000..b6a0b43b93d
Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/assets/prod/app-icon.icon/Assets/text.svg
similarity index 100%
rename from apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg
rename to assets/prod/app-icon.icon/Assets/text.svg
diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/assets/prod/app-icon.icon/icon.json
similarity index 88%
rename from apps/mobile/assets/icon-composer-prod.icon/icon.json
rename to assets/prod/app-icon.icon/icon.json
index 8f7579311f2..8c12c022ef8 100644
--- a/apps/mobile/assets/icon-composer-prod.icon/icon.json
+++ b/assets/prod/app-icon.icon/icon.json
@@ -6,8 +6,8 @@
{
"layers": [
{
- "image-name": "T3.svg",
- "name": "T3",
+ "image-name": "text.svg",
+ "name": "Text",
"position": {
"scale": 10,
"translation-in-points": [0, 0]
diff --git a/assets/prod/black-ios-1024.png b/assets/prod/black-ios-1024.png
index 2ff311ce786..a8a458337ab 100644
Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ
diff --git a/assets/prod/black-macos-1024.png b/assets/prod/black-macos-1024.png
index 0a6e1cbfcff..f5b68b1a6d4 100644
Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ
diff --git a/assets/prod/black-universal-1024.png b/assets/prod/black-universal-1024.png
index 88317dd41fb..a8a458337ab 100644
Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ
diff --git a/assets/prod/t3-black-web-apple-touch-180.png b/assets/prod/t3-black-web-apple-touch-180.png
index 9c593ce352c..2e30aecbc72 100644
Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ
diff --git a/assets/prod/t3-black-web-favicon-16x16.png b/assets/prod/t3-black-web-favicon-16x16.png
index f85017caf75..c175f2c87a2 100644
Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ
diff --git a/assets/prod/t3-black-web-favicon-32x32.png b/assets/prod/t3-black-web-favicon-32x32.png
index fae2d285b7a..fe6fb545b85 100644
Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ
diff --git a/assets/prod/t3-black-web-favicon.ico b/assets/prod/t3-black-web-favicon.ico
index e3ab4ae5e02..a0fe86da92f 100644
Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ
diff --git a/assets/prod/t3-black-windows.ico b/assets/prod/t3-black-windows.ico
index e3ab4ae5e02..a0fe86da92f 100644
Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ
diff --git a/package.json b/package.json
index f0ffcb3349c..3c0a8cc6b77 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,8 @@
"start:marketing": "vp run --filter @t3tools/marketing preview",
"start:mock-update-server": "node scripts/mock-update-server.ts",
"screenshots:mobile": "node scripts/mobile-showcase.ts",
+ "icons:export": "node scripts/export-brand-icons.ts",
+ "icons:check": "node scripts/export-brand-icons.ts --check",
"build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
"build:marketing": "vp run --filter @t3tools/marketing build",
"build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts
new file mode 100644
index 00000000000..c24169dc2e1
--- /dev/null
+++ b/scripts/export-brand-icons.ts
@@ -0,0 +1,800 @@
+#!/usr/bin/env node
+
+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import * as Console from "effect/Console";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { Command, Flag } from "effect/unstable/cli";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
+import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts";
+
+const DESIGN_GENERATION = 26;
+const ICON_COMPOSER_EXECUTABLE_PARTS = [
+ "Contents",
+ "Applications",
+ "Icon Composer.app",
+ "Contents",
+ "Executables",
+ "ictool",
+] as const;
+const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [
+ "Icon Composer.app",
+ "Contents",
+ "Executables",
+ "ictool",
+] as const;
+const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
+
+const IconComposerVersion = Schema.Struct({
+ "bundle-version": Schema.NonEmptyString,
+ "short-bundle-version": Schema.NonEmptyString,
+});
+const decodeIconComposerVersion = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(IconComposerVersion),
+);
+
+type IconPlatform = "iOS";
+
+interface VariantOutputs {
+ readonly ios: string;
+ readonly macos: string;
+ readonly universal: string;
+ readonly appleTouch: string;
+ readonly favicon16: string;
+ readonly favicon32: string;
+ readonly faviconIco: string;
+ readonly windowsIco: string;
+}
+
+interface IconVariant {
+ readonly label: string;
+ readonly source: string;
+ readonly outputs: VariantOutputs;
+}
+
+interface IconComposerTool {
+ readonly path: string;
+ readonly version: string;
+ readonly bundleVersion: string;
+ readonly supportsDesignGeneration: boolean;
+}
+
+interface CommandResult {
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly exitCode: number;
+}
+
+export class IconExportFileSystemError extends Schema.TaggedErrorClass()(
+ "IconExportFileSystemError",
+ {
+ operation: Schema.Literals([
+ "resolve-repository-root",
+ "check-path",
+ "read-directory",
+ "read-file",
+ "make-directory",
+ "make-temp-directory",
+ "make-temp-file",
+ "write-file",
+ "rename-file",
+ ]),
+ path: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`;
+ }
+}
+
+export class IconExportProcessError extends Schema.TaggedErrorClass()(
+ "IconExportProcessError",
+ {
+ operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]),
+ command: Schema.String,
+ argumentCount: NonNegativeInt,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Icon export process operation '${this.operation}' failed for ${this.command}.`;
+ }
+}
+
+export class IconExportCommandFailedError extends Schema.TaggedErrorClass()(
+ "IconExportCommandFailedError",
+ {
+ command: Schema.String,
+ argumentCount: NonNegativeInt,
+ exitCode: Schema.Int,
+ sourcePath: Schema.String,
+ size: Schema.Int,
+ stdout: Schema.optional(Schema.String),
+ stderr: Schema.optional(Schema.String),
+ },
+) {
+ override get message(): string {
+ return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`;
+ }
+}
+
+export class IconExportToolResolutionError extends Schema.TaggedErrorClass()(
+ "IconExportToolResolutionError",
+ {
+ reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]),
+ designGeneration: Schema.Int,
+ toolPath: Schema.optional(Schema.String),
+ version: Schema.optional(Schema.String),
+ },
+) {
+ override get message(): string {
+ switch (this.reason) {
+ case "configured-invalid":
+ return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`;
+ case "configured-outdated":
+ return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`;
+ case "not-found":
+ return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`;
+ }
+ }
+}
+
+export class IconExportSourceMissingError extends Schema.TaggedErrorClass()(
+ "IconExportSourceMissingError",
+ {
+ sourcePath: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Missing Icon Composer source project: ${this.sourcePath}`;
+ }
+}
+
+export class IconExportRenditionError extends Schema.TaggedErrorClass()(
+ "IconExportRenditionError",
+ {
+ sourcePath: Schema.String,
+ outputPath: Schema.String,
+ expectedSize: Schema.Int,
+ actualWidth: Schema.optional(Schema.Int),
+ actualHeight: Schema.optional(Schema.Int),
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ const actual =
+ this.actualWidth === undefined || this.actualHeight === undefined
+ ? "an invalid PNG"
+ : `${this.actualWidth}x${this.actualHeight}`;
+ return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`;
+ }
+}
+
+export class IconExportEncodingError extends Schema.TaggedErrorClass()(
+ "IconExportEncodingError",
+ {
+ variant: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Failed to encode ICO renditions for the ${this.variant} icon.`;
+ }
+}
+
+export class IconExportAssetsStaleError extends Schema.TaggedErrorClass()(
+ "IconExportAssetsStaleError",
+ {
+ paths: Schema.Array(Schema.String),
+ },
+) {
+ override get message(): string {
+ return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`;
+ }
+}
+
+const ICON_VARIANTS = [
+ {
+ label: "development",
+ source: BRAND_ASSET_PATHS.developmentIconComposerProject,
+ outputs: {
+ ios: BRAND_ASSET_PATHS.developmentIosIconPng,
+ macos: BRAND_ASSET_PATHS.developmentDesktopIconPng,
+ universal: BRAND_ASSET_PATHS.developmentUniversalIconPng,
+ appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
+ favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
+ favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
+ faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
+ windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco,
+ },
+ },
+ {
+ label: "preview",
+ source: BRAND_ASSET_PATHS.nightlyIconComposerProject,
+ outputs: {
+ ios: BRAND_ASSET_PATHS.nightlyIosIconPng,
+ macos: BRAND_ASSET_PATHS.nightlyMacIconPng,
+ universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
+ appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng,
+ favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png,
+ favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png,
+ faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
+ windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
+ },
+ },
+ {
+ label: "production",
+ source: BRAND_ASSET_PATHS.productionIconComposerProject,
+ outputs: {
+ ios: BRAND_ASSET_PATHS.productionIosIconPng,
+ macos: BRAND_ASSET_PATHS.productionMacIconPng,
+ universal: BRAND_ASSET_PATHS.productionLinuxIconPng,
+ appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
+ favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png,
+ favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png,
+ faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
+ windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
+ },
+ },
+] as const satisfies ReadonlyArray;
+
+const MACOS_EXPORT_CODEX_PROMPT = [
+ "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.",
+ "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:",
+ ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
+ "Do not resize, composite, or otherwise post-process the exported PNGs.",
+ "Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.",
+];
+
+const RepositoryRoot = Effect.service(Path.Path).pipe(
+ Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "resolve-repository-root",
+ path: new URL("..", import.meta.url).pathname,
+ cause,
+ }),
+ ),
+);
+
+const collectStreamAsString = (stream: Stream.Stream) =>
+ stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (acc, chunk) => acc + chunk,
+ ),
+ );
+
+const runCommand = Effect.fn("iconExport.runCommand")(function* (
+ command: string,
+ args: ReadonlyArray,
+) {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportProcessError({
+ operation: "spawn",
+ command,
+ argumentCount: args.length,
+ cause,
+ }),
+ ),
+ );
+ const [stdout, stderr, exitCode] = yield* Effect.all(
+ [
+ collectStreamAsString(child.stdout).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportProcessError({
+ operation: "collect-stdout",
+ command,
+ argumentCount: args.length,
+ cause,
+ }),
+ ),
+ ),
+ collectStreamAsString(child.stderr).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportProcessError({
+ operation: "collect-stderr",
+ command,
+ argumentCount: args.length,
+ cause,
+ }),
+ ),
+ ),
+ child.exitCode.pipe(
+ Effect.map(Number),
+ Effect.mapError(
+ (cause) =>
+ new IconExportProcessError({
+ operation: "wait-for-exit",
+ command,
+ argumentCount: args.length,
+ cause,
+ }),
+ ),
+ ),
+ ],
+ { concurrency: "unbounded" },
+ );
+
+ return { stdout, stderr, exitCode } satisfies CommandResult;
+});
+
+const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) =>
+ path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1));
+
+const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")(
+ function* () {
+ const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option);
+ return Option.flatMap(result, (output) => {
+ const developerDirectory = output.stdout.trim();
+ return output.exitCode === 0 && developerDirectory.length > 0
+ ? Option.some(developerDirectory)
+ : Option.none();
+ });
+ },
+);
+
+const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* (
+ directory: string,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const entries = yield* fs.readDirectory(directory).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "read-directory",
+ path: directory,
+ cause,
+ }),
+ ),
+ Effect.orElseSucceed(() => []),
+ );
+ return entries
+ .filter((entry) => /^Xcode.*\.app$/.test(entry))
+ .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS));
+});
+
+const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* (
+ candidate: string,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const exists = yield* fs.exists(candidate).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "check-path",
+ path: candidate,
+ cause,
+ }),
+ ),
+ Effect.orElseSucceed(() => false),
+ );
+ if (!exists) return Option.none();
+
+ const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option);
+ if (Option.isNone(result) || result.value.exitCode !== 0) {
+ return Option.none();
+ }
+
+ const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option);
+ if (Option.isNone(version)) return Option.none();
+
+ const bundleVersion = version.value["bundle-version"];
+ const shortVersion = version.value["short-bundle-version"];
+ return Option.some({
+ path: candidate,
+ version: `${shortVersion} (${bundleVersion})`,
+ bundleVersion,
+ supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2,
+ });
+});
+
+const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () {
+ const path = yield* Path.Path;
+ const environment = yield* HostProcessEnvironment;
+ const configuredTool = environment.ICON_COMPOSER_TOOL?.trim();
+ if (configuredTool) {
+ const tool = yield* probeIconComposerTool(configuredTool);
+ if (Option.isNone(tool)) {
+ return yield* new IconExportToolResolutionError({
+ reason: "configured-invalid",
+ designGeneration: DESIGN_GENERATION,
+ toolPath: configuredTool,
+ });
+ }
+ if (!tool.value.supportsDesignGeneration) {
+ return yield* new IconExportToolResolutionError({
+ reason: "configured-outdated",
+ designGeneration: DESIGN_GENERATION,
+ toolPath: configuredTool,
+ version: tool.value.version,
+ });
+ }
+ return tool.value;
+ }
+
+ const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory();
+ const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim();
+ const homeDirectory = environment.HOME?.trim();
+ const searchDirectories = [
+ "/Applications",
+ ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []),
+ ];
+ const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, {
+ concurrency: "unbounded",
+ });
+ const candidates = new Set([
+ ...(configuredDeveloperDirectory
+ ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)]
+ : []),
+ ...Option.match(selectedDeveloperDirectory, {
+ onNone: () => [],
+ onSome: (developerDirectory) => [
+ iconComposerToolFromDeveloperDirectory(developerDirectory, path),
+ ],
+ }),
+ path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS),
+ ...(homeDirectory
+ ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)]
+ : []),
+ ...xcodeCandidates.flat(),
+ ]);
+ const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, {
+ concurrency: "unbounded",
+ });
+ const compatibleTools = probed
+ .filter(Option.isSome)
+ .map((tool) => tool.value)
+ .filter((tool) => tool.supportsDesignGeneration)
+ .sort((left, right) =>
+ right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }),
+ );
+ const newestTool = compatibleTools[0];
+ if (newestTool) return newestTool;
+
+ return yield* new IconExportToolResolutionError({
+ reason: "not-found",
+ designGeneration: DESIGN_GENERATION,
+ });
+});
+
+const renderIcon = Effect.fn("iconExport.renderIcon")(function* (
+ toolPath: string,
+ sourcePath: string,
+ outputPath: string,
+ platform: IconPlatform,
+ size: number,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const args = [
+ sourcePath,
+ "--export-image",
+ "--output-file",
+ outputPath,
+ "--platform",
+ platform,
+ "--rendition",
+ "Default",
+ "--width",
+ String(size),
+ "--height",
+ String(size),
+ "--scale",
+ "1",
+ "--design-generation",
+ String(DESIGN_GENERATION),
+ ];
+ const result = yield* runCommand(toolPath, args);
+ if (result.exitCode !== 0) {
+ return yield* new IconExportCommandFailedError({
+ command: toolPath,
+ argumentCount: args.length,
+ exitCode: result.exitCode,
+ sourcePath,
+ size,
+ ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}),
+ ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}),
+ });
+ }
+
+ const contents = yield* fs.readFile(outputPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "read-file",
+ path: outputPath,
+ cause,
+ }),
+ ),
+ );
+ const buffer = Buffer.from(contents);
+ const dimensions = yield* Effect.try({
+ try: () => readPngDimensions(buffer),
+ catch: (cause) =>
+ new IconExportRenditionError({
+ sourcePath,
+ outputPath,
+ expectedSize: size,
+ cause,
+ }),
+ });
+ if (dimensions.width !== size || dimensions.height !== size) {
+ return yield* new IconExportRenditionError({
+ sourcePath,
+ outputPath,
+ expectedSize: size,
+ actualWidth: dimensions.width,
+ actualHeight: dimensions.height,
+ });
+ }
+ return buffer;
+});
+
+const renderVariant = Effect.fn("iconExport.renderVariant")(function* (
+ toolPath: string,
+ repositoryRoot: string,
+ temporaryDirectory: string,
+ variant: IconVariant,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sourcePath = path.join(repositoryRoot, variant.source);
+ const sourceExists = yield* fs.exists(sourcePath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "check-path",
+ path: sourcePath,
+ cause,
+ }),
+ ),
+ );
+ if (!sourceExists) {
+ return yield* new IconExportSourceMissingError({ sourcePath: variant.source });
+ }
+
+ const renditionCache = new Map();
+ const render = Effect.fn("iconExport.renderVariant.rendition")(function* (
+ platform: IconPlatform,
+ size: number,
+ ) {
+ const cacheKey = `${platform}-${size}`;
+ const cached = renditionCache.get(cacheKey);
+ if (cached) return cached;
+
+ const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`);
+ const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size);
+ renditionCache.set(cacheKey, contents);
+ return contents;
+ });
+
+ const ios = yield* render("iOS", 1024);
+ const icoRenditions = yield* Effect.forEach(
+ WINDOWS_ICON_SIZES,
+ (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))),
+ { concurrency: 1 },
+ );
+ const ico = yield* Effect.try({
+ try: () => encodePngIco(icoRenditions),
+ catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }),
+ });
+
+ return new Map([
+ [variant.outputs.ios, ios],
+ [variant.outputs.universal, ios],
+ [variant.outputs.appleTouch, yield* render("iOS", 180)],
+ [variant.outputs.favicon16, yield* render("iOS", 16)],
+ [variant.outputs.favicon32, yield* render("iOS", 32)],
+ [variant.outputs.faviconIco, ico],
+ [variant.outputs.windowsIco, ico],
+ ]);
+});
+
+const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")(
+ function* () {
+ yield* Console.warn(
+ [
+ "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.",
+ "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:",
+ ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
+ "See assets/README.md for the complete workflow.",
+ "",
+ "Copy/paste this prompt into Codex to perform the native exports:",
+ "---",
+ ...MACOS_EXPORT_CODEX_PROMPT,
+ "---",
+ ].join("\n"),
+ );
+ },
+);
+
+const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* (
+ repositoryRoot: string,
+ relativePath: string,
+ contents: Buffer,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const targetPath = path.join(repositoryRoot, relativePath);
+ const targetDirectory = path.dirname(targetPath);
+ yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "make-directory",
+ path: targetDirectory,
+ cause,
+ }),
+ ),
+ );
+ const temporaryPath = yield* fs
+ .makeTempFileScoped({
+ directory: targetDirectory,
+ prefix: ".t3-icon-export-",
+ suffix: ".tmp",
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "make-temp-file",
+ path: targetDirectory,
+ cause,
+ }),
+ ),
+ );
+ yield* fs.writeFile(temporaryPath, contents).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "write-file",
+ path: temporaryPath,
+ cause,
+ }),
+ ),
+ );
+ yield* fs.rename(temporaryPath, targetPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "rename-file",
+ path: targetPath,
+ cause,
+ }),
+ ),
+ );
+});
+
+const isCurrent = Effect.fn("iconExport.isCurrent")(function* (
+ repositoryRoot: string,
+ relativePath: string,
+ expected: Buffer,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const targetPath = path.join(repositoryRoot, relativePath);
+ const exists = yield* fs.exists(targetPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "check-path",
+ path: targetPath,
+ cause,
+ }),
+ ),
+ );
+ if (!exists) return false;
+
+ const actual = yield* fs.readFile(targetPath).pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "read-file",
+ path: targetPath,
+ cause,
+ }),
+ ),
+ );
+ return Buffer.from(actual).equals(expected);
+});
+
+export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) {
+ const fs = yield* FileSystem.FileSystem;
+ const repositoryRoot = yield* RepositoryRoot;
+ const tool = yield* resolveIconComposerTool();
+ const temporaryDirectory = yield* fs
+ .makeTempDirectoryScoped({
+ prefix: "t3-icon-export-",
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new IconExportFileSystemError({
+ operation: "make-temp-directory",
+ path: "system temporary directory",
+ cause,
+ }),
+ ),
+ );
+ yield* Console.log(
+ `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`,
+ );
+
+ const generated = new Map();
+ for (const variant of ICON_VARIANTS) {
+ yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`);
+ const variantAssets = yield* renderVariant(
+ tool.path,
+ repositoryRoot,
+ temporaryDirectory,
+ variant,
+ );
+ for (const [relativePath, contents] of variantAssets) {
+ generated.set(relativePath, contents);
+ }
+ }
+
+ if (checkOnly) {
+ const stale = yield* Effect.filter(
+ [...generated.entries()],
+ ([relativePath, contents]) =>
+ isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)),
+ { concurrency: "unbounded" },
+ );
+ if (stale.length > 0) {
+ return yield* new IconExportAssetsStaleError({
+ paths: stale.map(([relativePath]) => relativePath),
+ });
+ }
+ yield* Console.log(`All ${generated.size} generated icon assets are current.`);
+ yield* logManualMacOsExportInstructions();
+ return;
+ }
+
+ yield* Effect.forEach(
+ generated,
+ ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents),
+ { concurrency: 1, discard: true },
+ );
+ yield* Console.log(`Updated ${generated.size} generated icon assets.`);
+ yield* logManualMacOsExportInstructions();
+});
+
+export const exportBrandIconsCommand = Command.make(
+ "export-brand-icons",
+ {
+ check: Flag.boolean("check").pipe(
+ Flag.withDescription("Verify generated icon assets without modifying files."),
+ Flag.withDefault(false),
+ ),
+ },
+ ({ check }) => exportBrandIcons(check).pipe(Effect.scoped),
+).pipe(
+ Command.withDescription(
+ "Export development, preview, and production assets from Icon Composer projects.",
+ ),
+);
+
+if (import.meta.main) {
+ Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe(
+ Effect.provide(NodeServices.layer),
+ NodeRuntime.runMain,
+ );
+}
diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts
index 8e1875e8d27..6ab3c6a3a29 100644
--- a/scripts/lib/brand-assets.test.ts
+++ b/scripts/lib/brand-assets.test.ts
@@ -55,4 +55,19 @@ describe("brand-assets", () => {
expect(resolveWebAssetBrandForChannel("latest")).toBe("production");
expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly");
});
+
+ it("keeps development, nightly, and production icon families separate", () => {
+ expect([
+ BRAND_ASSET_PATHS.developmentIconComposerProject,
+ BRAND_ASSET_PATHS.nightlyIconComposerProject,
+ BRAND_ASSET_PATHS.productionIconComposerProject,
+ ]).toEqual([
+ "assets/dev/app-icon.icon",
+ "assets/nightly/app-icon.icon",
+ "assets/prod/app-icon.icon",
+ ]);
+ expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/);
+ expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/);
+ expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/);
+ });
});
diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts
index 87b0cab219e..fe02c975ea3 100644
--- a/scripts/lib/brand-assets.ts
+++ b/scripts/lib/brand-assets.ts
@@ -1,4 +1,10 @@
export const BRAND_ASSET_PATHS = {
+ developmentIconComposerProject: "assets/dev/app-icon.icon",
+ developmentIosIconPng: "assets/dev/blueprint-ios-1024.png",
+ developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png",
+
+ productionIconComposerProject: "assets/prod/app-icon.icon",
+ productionIosIconPng: "assets/prod/black-ios-1024.png",
productionMacIconPng: "assets/prod/black-macos-1024.png",
productionLinuxIconPng: "assets/prod/black-universal-1024.png",
productionWindowsIconIco: "assets/prod/t3-black-windows.ico",
@@ -7,13 +13,15 @@ export const BRAND_ASSET_PATHS = {
productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",
- nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
- nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
- nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
- nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico",
- nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png",
- nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png",
- nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png",
+ nightlyIconComposerProject: "assets/nightly/app-icon.icon",
+ nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png",
+ nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png",
+ nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png",
+ nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico",
+ nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico",
+ nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png",
+ nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png",
+ nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png",
developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
diff --git a/scripts/lib/icon-export.test.ts b/scripts/lib/icon-export.test.ts
new file mode 100644
index 00000000000..3db6f22cbdb
--- /dev/null
+++ b/scripts/lib/icon-export.test.ts
@@ -0,0 +1,47 @@
+import { assert, describe, it } from "@effect/vitest";
+
+import { encodePngIco, readPngDimensions } from "./icon-export.ts";
+
+const pngHeader = (width: number, height: number) => {
+ const contents = Buffer.alloc(24);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents);
+ contents.write("IHDR", 12, "ascii");
+ contents.writeUInt32BE(width, 16);
+ contents.writeUInt32BE(height, 20);
+ return contents;
+};
+
+describe("icon export", () => {
+ it("reads dimensions from a PNG IHDR chunk", () => {
+ assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 });
+ });
+
+ it("encodes PNG renditions into an ICO directory", () => {
+ const small = pngHeader(16, 16);
+ const large = pngHeader(256, 256);
+ const ico = encodePngIco([
+ { size: 16, contents: small },
+ { size: 256, contents: large },
+ ]);
+
+ assert.equal(ico.readUInt16LE(2), 1);
+ assert.equal(ico.readUInt16LE(4), 2);
+ assert.equal(ico.readUInt8(6), 16);
+ assert.equal(ico.readUInt8(22), 0);
+ assert.equal(ico.readUInt32LE(18), 38);
+ assert.equal(ico.readUInt32LE(34), 38 + small.length);
+ assert.deepEqual(ico.subarray(38, 38 + small.length), small);
+ assert.deepEqual(ico.subarray(38 + small.length), large);
+ });
+
+ it("rejects duplicate ICO rendition sizes", () => {
+ assert.throws(
+ () =>
+ encodePngIco([
+ { size: 32, contents: pngHeader(32, 32) },
+ { size: 32, contents: pngHeader(32, 32) },
+ ]),
+ /provided more than once/,
+ );
+ });
+});
diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts
new file mode 100644
index 00000000000..f48228a94fd
--- /dev/null
+++ b/scripts/lib/icon-export.ts
@@ -0,0 +1,72 @@
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const;
+
+export interface PngIconImage {
+ readonly size: number;
+ readonly contents: Buffer;
+}
+
+export function readPngDimensions(contents: Buffer): {
+ readonly width: number;
+ readonly height: number;
+} {
+ if (
+ contents.length < 24 ||
+ !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) ||
+ contents.toString("ascii", 12, 16) !== "IHDR"
+ ) {
+ throw new Error("Icon Composer produced an invalid PNG.");
+ }
+
+ return {
+ width: contents.readUInt32BE(16),
+ height: contents.readUInt32BE(20),
+ };
+}
+
+/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */
+export function encodePngIco(images: ReadonlyArray): Buffer {
+ if (images.length === 0) {
+ throw new Error("An ICO file requires at least one PNG rendition.");
+ }
+
+ const seenSizes = new Set();
+ for (const image of images) {
+ if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) {
+ throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`);
+ }
+ if (seenSizes.has(image.size)) {
+ throw new Error(`ICO rendition size ${image.size} was provided more than once.`);
+ }
+ if (image.contents.length === 0) {
+ throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`);
+ }
+ seenSizes.add(image.size);
+ }
+
+ const headerSize = 6;
+ const directoryEntrySize = 16;
+ const directorySize = directoryEntrySize * images.length;
+ const header = Buffer.alloc(headerSize + directorySize);
+ header.writeUInt16LE(0, 0);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(images.length, 4);
+
+ let imageOffset = header.length;
+ images.forEach((image, index) => {
+ const entryOffset = headerSize + index * directoryEntrySize;
+ const encodedSize = image.size === 256 ? 0 : image.size;
+ header.writeUInt8(encodedSize, entryOffset);
+ header.writeUInt8(encodedSize, entryOffset + 1);
+ header.writeUInt8(0, entryOffset + 2);
+ header.writeUInt8(0, entryOffset + 3);
+ header.writeUInt16LE(1, entryOffset + 4);
+ header.writeUInt16LE(32, entryOffset + 6);
+ header.writeUInt32LE(image.contents.length, entryOffset + 8);
+ header.writeUInt32LE(imageOffset, entryOffset + 12);
+ imageOffset += image.contents.length;
+ });
+
+ return Buffer.concat([header, ...images.map((image) => image.contents)]);
+}