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)]); +}