authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-17 18:51:14+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-17 18:51:14+02:00
log6e9987cf17eef56303a7e5033aa152a92b234d65
treeda6b221e4b1685833f8d73f0f9d1ce06f9b9079e
parent8b5469863ae1dd696e696de30240ec3da607962d
signature Signed by PGP key B5690EEEBB952194

Refresh app icons across release variants (#4080)

Co-authored-by: codex <codex@users.noreply.github.com>

65 files changed, 1393 insertions(+), 150 deletions(-)

apps/mobile/app.config.ts+22-8
......@@ -1,5 +1,6 @@
11import type { ExpoConfig } from "expo/config";
22
3import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts";
34import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
45
56type AppVariant = "development" | "preview" | "production";
......@@ -13,6 +14,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
1314const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
1415const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
1516
17const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
18
1619if (
1720 isIosPersonalTeamBuild &&
1821 (!personalTeamBundleIdentifier ||
......@@ -24,20 +27,31 @@ if (
2427}
2528
2629const DEVELOPMENT_ASSETS = {
27 appIcon: "./assets/splash-icon-dev.png",
28 iosIcon: "./assets/icon-composer-dev.icon",
29 splashIcon: "./assets/splash-icon-dev.png",
30 androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png",
30 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
31 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
32 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
33 androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng),
3134 androidAdaptiveBackgroundColor: "#00639B",
3235 androidMonochromeIcon: "./assets/android-icon-mark.png",
3336 androidNotificationIcon: "./assets/android-notification-icon.png",
3437 androidNotificationColor: "#00639B",
3538} as const;
3639
40const PREVIEW_ASSETS = {
41 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
42 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject),
43 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
44 androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng),
45 androidAdaptiveBackgroundColor: "#111533",
46 androidMonochromeIcon: "./assets/android-icon-mark.png",
47 androidNotificationIcon: "./assets/android-notification-icon.png",
48 androidNotificationColor: "#7565C7",
49} as const;
50
3751const RELEASE_ASSETS = {
38 appIcon: "./assets/splash-icon-prod.png",
39 iosIcon: "./assets/icon-composer-prod.icon",
40 splashIcon: "./assets/splash-icon-prod.png",
52 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
53 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject),
54 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
4155 androidAdaptiveForeground: "./assets/android-icon-mark.png",
4256 androidAdaptiveBackgroundColor: "#000000",
4357 androidMonochromeIcon: "./assets/android-icon-mark.png",
......@@ -60,7 +74,7 @@ const VARIANT_CONFIG = {
6074 iosBundleIdentifier: "com.t3tools.t3code.preview",
6175 androidPackage: "com.t3tools.t3code.preview",
6276 relyingParty: "clerk.t3.codes",
63 assets: RELEASE_ASSETS,
77 assets: PREVIEW_ASSETS,
6478 },
6579 production: {
6680 appName: "T3 Code",
apps/mobile/assets/android-icon-dev-foreground.png deleted
Binary files a/apps/mobile/assets/android-icon-dev-foreground.png and /dev/null differ
apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg deleted-3
......@@ -1,3 +0,0 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted
Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ
apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted
Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ
apps/mobile/assets/icon-composer-dev.icon/icon.json deleted-45
......@@ -1,45 +0,0 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "hidden": false,
10 "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png",
11 "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr",
12 "position": {
13 "scale": 1.1,
14 "translation-in-points": [0, 0]
15 }
16 },
17 {
18 "image-name": "T3.svg",
19 "name": "T3",
20 "position": {
21 "scale": 10,
22 "translation-in-points": [0, 0]
23 }
24 },
25 {
26 "hidden": true,
27 "image-name": "Texturelabs_Paper_381XL.jpg",
28 "name": "Texturelabs_Paper_381XL"
29 }
30 ],
31 "shadow": {
32 "kind": "neutral",
33 "opacity": 0.5
34 },
35 "translucency": {
36 "enabled": true,
37 "value": 0.5
38 }
39 }
40 ],
41 "supported-platforms": {
42 "circles": ["watchOS"],
43 "squares": "shared"
44 }
45}
apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted-3
......@@ -1,3 +0,0 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
apps/mobile/assets/icon-composer-prod.icon/icon.json deleted-31
......@@ -1,31 +0,0 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "image-name": "T3.svg",
10 "name": "T3",
11 "position": {
12 "scale": 10,
13 "translation-in-points": [0, 0]
14 }
15 }
16 ],
17 "shadow": {
18 "kind": "neutral",
19 "opacity": 0.5
20 },
21 "translucency": {
22 "enabled": true,
23 "value": 0.5
24 }
25 }
26 ],
27 "supported-platforms": {
28 "circles": ["watchOS"],
29 "squares": "shared"
30 }
31}
apps/mobile/assets/splash-icon-dev.png deleted
Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ
apps/mobile/assets/splash-icon-prod.png deleted
Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ
apps/mobile/src/components/BrandMark.tsx+13-3
......@@ -1,13 +1,23 @@
1import { Image, View } from "react-native";
1import Constants from "expo-constants";
2import { Image } from "expo-image";
3import { View } from "react-native";
24
35import { AppText as Text } from "./AppText";
46
5const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
7const appVariant = Constants.expoConfig?.extra?.appVariant;
8const BRAND_MARK_SOURCE =
9 appVariant === "development"
10 ? require("../../../../assets/dev/blueprint-ios-1024.png")
11 : appVariant === "preview"
12 ? require("../../../../assets/nightly/nightly-ios-1024.png")
13 : require("../../../../assets/prod/black-ios-1024.png");
14const DEFAULT_STAGE_LABEL =
15 appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
616
717export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
818 const compact = props.compact ?? false;
919 const iconSize = compact ? 32 : 44;
10 const stageLabel = props.stageLabel ?? "Alpha";
20 const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL;
1121
1222 return (
1323 <View className="flex-row items-center gap-3">
assets/README.md created+50
......@@ -0,0 +1,50 @@
1# Brand icons
2
3The three Icon Composer projects are the source of truth for full application icons:
4
5- `dev/app-icon.icon`
6- `nightly/app-icon.icon`
7- `prod/app-icon.icon`
8
9Each 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.
10
11Run `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.
12
13Exporting 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.
14
15## macOS exports
16
17Icon 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.
18
19After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings:
20
21- Platform: `macOS pre-Tahoe`
22- Appearance: `Default`
23- Size: `1024pt`
24- Scale: `1×`
25
26Save the three exports to:
27
28- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png`
29- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png`
30- `prod/app-icon.icon` -> `prod/black-macos-1024.png`
31
32The 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.
33
34To have Codex perform the native exports, paste this prompt into a task opened at the repository root:
35
36```text
37Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.
38
39For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:
40
41- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png
42- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png
43- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png
44
45Do not resize, composite, or otherwise post-process the exported PNGs.
46
47Verify 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.
48```
49
50Do not edit the generated PNG or ICO files directly.
assets/dev/app-icon.icon/Assets/annotations.svg created+37
......@@ -0,0 +1,37 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <marker id="arrowStart" markerWidth="5" markerHeight="5" refX="1.2" refY="2.5" orient="auto" markerUnits="strokeWidth">
4 <path d="M4.5 0.6L1 2.5L4.5 4.4" fill="none" stroke="#DDF7FF" stroke-width="0.8" stroke-linecap="round"/>
5 </marker>
6 <marker id="arrowEnd" markerWidth="5" markerHeight="5" refX="3.8" refY="2.5" orient="auto" markerUnits="strokeWidth">
7 <path d="M0.5 0.6L4 2.5L0.5 4.4" fill="none" stroke="#DDF7FF" stroke-width="0.8" stroke-linecap="round"/>
8 </marker>
9 </defs>
10
11 <g stroke="#DDF7FF" stroke-linecap="round" stroke-linejoin="round">
12 <g opacity="0.9" stroke-width="0.44">
13 <path d="M15.5309 31V25.6M64.3309 31V25.6M68.1653 31V25.6M107.365 31V25.6"/>
14 <path d="M16.9 27.2H62.96" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
15 <path d="M69.54 27.2H105.99" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
16 <path d="M33.4509 97.3V103M46.4109 97.3V103M65.3653 97.3V103M109.925 97.3V103"/>
17 <path d="M34.82 101.1H45.04" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
18 <path d="M66.74 101.1H108.55" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
19 <path d="M10.2 37H6.1M10.2 47.6H6.1M10.2 93H6.1"/>
20 <path d="M7.9 38.4V46.2" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
21 <path d="M7.9 49V91.6" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
22 </g>
23
24 <g opacity="0.58" stroke-width="0.34">
25 <path d="M12.5 35.1H66.8M39.9309 94.8V44.5M64.3309 34.2V49.9M68.1653 34.2V49.9" stroke-dasharray="1.6 1.6"/>
26 <path d="M65.3653 65.2H109.925M87.2 32.7V98.2" stroke-dasharray="1.2 2"/>
27 <circle cx="87.2" cy="65.2" r="20.8"/>
28 <circle cx="87.2" cy="65.2" r="1.1"/>
29 </g>
30
31 <g opacity="0.76" stroke-width="0.4">
32 <path d="M24 20V24M22 22H26M101 17V21M99 19H103M114 106V110M112 108H116"/>
33 <path d="M54 107L57 110M57 107L54 110M16 74L19 77M19 74L16 77"/>
34 <path d="M74 22H82M78 18V26" stroke-dasharray="0.7 1.1"/>
35 </g>
36 </g>
37</svg>
assets/dev/app-icon.icon/Assets/background.svg created+51
......@@ -0,0 +1,51 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="paper" x1="36" y1="0" x2="92" y2="128" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#67C2FF"/>
5 <stop offset="0.44" stop-color="#347FF8"/>
6 <stop offset="1" stop-color="#1538D0"/>
7 </linearGradient>
8 <radialGradient id="cyanGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(92 22) rotate(137) scale(78 70)">
9 <stop stop-color="#D4F6FF" stop-opacity="0.62"/>
10 <stop offset="0.52" stop-color="#65C8FF" stop-opacity="0.24"/>
11 <stop offset="1" stop-color="#276AF1" stop-opacity="0"/>
12 </radialGradient>
13 <radialGradient id="edgeShade" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(64 58) rotate(90) scale(77)">
14 <stop offset="0.68" stop-color="#102EBC" stop-opacity="0"/>
15 <stop offset="1" stop-color="#061A88" stop-opacity="0.48"/>
16 </radialGradient>
17 <pattern id="minorGrid" width="4" height="4" patternUnits="userSpaceOnUse">
18 <path d="M4 0H0V4" stroke="#D9F7FF" stroke-width="0.18" opacity="0.38"/>
19 </pattern>
20 <pattern id="majorGrid" width="16" height="16" patternUnits="userSpaceOnUse">
21 <rect width="16" height="16" fill="url(#minorGrid)"/>
22 <path d="M16 0H0V16" stroke="#F0FCFF" stroke-width="0.34" opacity="0.52"/>
23 </pattern>
24 <clipPath id="frame">
25 <rect width="128" height="128" rx="10"/>
26 </clipPath>
27 </defs>
28
29 <g clip-path="url(#frame)">
30 <rect width="128" height="128" fill="url(#paper)"/>
31 <rect width="128" height="128" fill="url(#cyanGlow)"/>
32 <rect x="4" y="4" width="120" height="120" fill="url(#majorGrid)" opacity="0.96"/>
33 <path d="M64 4V124M4 64H124" stroke="#F0FCFF" stroke-width="0.3" opacity="0.46"/>
34 <rect x="5.75" y="5.75" width="116.5" height="116.5" rx="5.5" stroke="#F1FCFF" stroke-width="0.5" opacity="0.66"/>
35
36 <g stroke="#BFE9F7" stroke-linecap="round" opacity="0.2">
37 <path d="M14 19L24 12M17 22L29 13" stroke-width="0.5"/>
38 <path d="M106 25L115 18M104 30L118 20" stroke-width="0.38"/>
39 <path d="M11 108L23 101M16 113L28 105" stroke-width="0.42"/>
40 <path d="M102 113L116 104M107 116L120 108" stroke-width="0.46"/>
41 </g>
42 <g fill="#D7F4FF" opacity="0.22">
43 <circle cx="18" cy="30" r="0.45"/>
44 <circle cx="112" cy="38" r="0.35"/>
45 <circle cx="22" cy="98" r="0.3"/>
46 <circle cx="109" cy="94" r="0.5"/>
47 <circle cx="94" cy="14" r="0.32"/>
48 </g>
49 <rect width="128" height="128" fill="url(#edgeShade)"/>
50 </g>
51</svg>
assets/dev/app-icon.icon/Assets/text.svg created+3
......@@ -0,0 +1,3 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
3</svg>
assets/dev/app-icon.icon/icon.json created+50
......@@ -0,0 +1,50 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "glass": true,
10 "image-name": "text.svg",
11 "name": "Text",
12 "position": {
13 "scale": 8.5,
14 "translation-in-points": [0, 0]
15 }
16 },
17 {
18 "glass": false,
19 "image-name": "annotations.svg",
20 "name": "Annotations",
21 "position": {
22 "scale": 8.5,
23 "translation-in-points": [0, 0]
24 }
25 },
26 {
27 "glass": true,
28 "image-name": "background.svg",
29 "name": "Background",
30 "position": {
31 "scale": 8.1,
32 "translation-in-points": [0, 0]
33 }
34 }
35 ],
36 "shadow": {
37 "kind": "neutral",
38 "opacity": 0.6
39 },
40 "translucency": {
41 "enabled": true,
42 "value": 0.2
43 }
44 }
45 ],
46 "supported-platforms": {
47 "circles": ["watchOS"],
48 "squares": "shared"
49 }
50}
assets/dev/blueprint-icon-composer.icon/Assets/T3.svg deleted-3
......@@ -1,3 +0,0 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg deleted
Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ
assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png deleted
Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ
assets/dev/blueprint-icon-composer.icon/icon.json deleted-47
......@@ -1,47 +0,0 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "blend-mode": "normal",
10 "fill": "automatic",
11 "glass": true,
12 "hidden": false,
13 "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png",
14 "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871",
15 "position": {
16 "scale": 1.05,
17 "translation-in-points": [0, 0]
18 }
19 },
20 {
21 "image-name": "T3.svg",
22 "name": "T3",
23 "position": {
24 "scale": 10,
25 "translation-in-points": [0, 0]
26 }
27 },
28 {
29 "hidden": false,
30 "image-name": "Texturelabs_Paper_381XL.jpg",
31 "name": "Texturelabs_Paper_381XL"
32 }
33 ],
34 "shadow": {
35 "kind": "neutral",
36 "opacity": 0.5
37 },
38 "translucency": {
39 "enabled": true,
40 "value": 0.5
41 }
42 }
43 ],
44 "supported-platforms": {
45 "squares": ["iOS", "macOS"]
46 }
47}
assets/dev/blueprint-ios-1024.png
Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ
assets/dev/blueprint-macos-1024.png
Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ
assets/dev/blueprint-universal-1024.png
Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ
assets/dev/blueprint-web-apple-touch-180.png
Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ
assets/dev/blueprint-web-favicon-16x16.png
Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ
assets/dev/blueprint-web-favicon-32x32.png
Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ
assets/dev/blueprint-web-favicon.ico
Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ
assets/dev/blueprint-windows.ico
Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ
assets/nightly/app-icon.icon/Assets/background.svg created+43
......@@ -0,0 +1,43 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="night" x1="12" y1="6" x2="116" y2="124" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#07152F"/>
5 <stop offset="0.5" stop-color="#151443"/>
6 <stop offset="1" stop-color="#32155B"/>
7 </linearGradient>
8 <radialGradient id="skyGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(90 28) rotate(137) scale(78 70)">
9 <stop stop-color="#5165D8" stop-opacity="0.4"/>
10 <stop offset="0.5" stop-color="#283075" stop-opacity="0.16"/>
11 <stop offset="1" stop-color="#111635" stop-opacity="0"/>
12 </radialGradient>
13 <radialGradient id="horizonGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(42 127) rotate(-70) scale(68 96)">
14 <stop stop-color="#6646B6" stop-opacity="0.34"/>
15 <stop offset="1" stop-color="#352066" stop-opacity="0"/>
16 </radialGradient>
17 <clipPath id="frame">
18 <rect width="128" height="128" rx="10"/>
19 </clipPath>
20 </defs>
21
22 <g clip-path="url(#frame)">
23 <rect width="128" height="128" fill="url(#night)"/>
24 <rect width="128" height="128" fill="url(#skyGlow)"/>
25 <rect width="128" height="128" fill="url(#horizonGlow)"/>
26
27
28 <!-- A few tiny stars kept to the perimeter so the wordmark stays clear. -->
29 <g fill="#E4EAFF">
30 <circle cx="17" cy="18" r="0.55" fill-opacity="0.82"/>
31 <circle cx="54" cy="14" r="0.4" fill-opacity="0.65"/>
32 <circle cx="116" cy="42" r="0.45" fill-opacity="0.72"/>
33 <circle cx="10" cy="70" r="0.35" fill-opacity="0.56"/>
34 <circle cx="117" cy="88" r="0.4" fill-opacity="0.6"/>
35 </g>
36 <path d="M31 22V24.8M29.6 23.4H32.4" stroke="#EEF2FF" stroke-width="0.5" stroke-linecap="round" opacity="0.72"/>
37 <path d="M111 58V60M110 59H112" stroke="#CBD8FF" stroke-width="0.45" stroke-linecap="round" opacity="0.58"/>
38
39 <!-- Broad horizon remains below the T3 baseline (y=94). -->
40 <path d="M-12 119C14 99 39 99 63 110C87 121 110 116 140 96V140H-12V119Z" fill="#20184E" fill-opacity="0.42"/>
41 <path d="M-12 129C15 111 42 112 66 122C91 132 114 124 140 107V140H-12V129Z" fill="#6946B8" fill-opacity="0.22"/>
42 </g>
43</svg>
assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg created+21
......@@ -0,0 +1,21 @@
1<svg width="64" height="32" viewBox="0 0 64 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="cloud" x1="7" y1="10" x2="57" y2="25" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#4EA4FF" stop-opacity="0.62"/>
5 <stop offset="0.52" stop-color="#696FEA" stop-opacity="0.72"/>
6 <stop offset="1" stop-color="#A85BEA" stop-opacity="0.58"/>
7 </linearGradient>
8 <radialGradient id="highlight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(30 14) rotate(13) scale(27 10)">
9 <stop stop-color="#C8D7FF" stop-opacity="0.4"/>
10 <stop offset="1" stop-color="#8C8EF2" stop-opacity="0"/>
11 </radialGradient>
12 <filter id="soft" x="-8" y="-8" width="80" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
13 <feGaussianBlur stdDeviation="2.5"/>
14 </filter>
15 </defs>
16
17 <g filter="url(#soft)">
18 <path d="M7 23.5C7 18.9 10.8 15.2 15.5 15.2C16.9 10.7 21.1 7.5 26.1 7.5C31 7.5 35.2 10.5 36.7 14.8C38.5 13.2 40.9 12.2 43.5 12.2C49.1 12.2 53.7 16.4 54.1 21.8C57 22.2 59.2 24 60 26.5H8.2C7.4 25.7 7 24.7 7 23.5Z" fill="url(#cloud)"/>
19 <path d="M11 22.9C12.7 19.8 16 18 19.5 18.5C21 13.4 25.6 10 30.8 10.6C35 11 38.3 13.9 39.4 17.7C43.8 15.5 49 17.3 51.1 21.7C46.2 20.3 41.9 21 37.7 22.1C29.6 24.2 21.5 21.3 11 22.9Z" fill="url(#highlight)"/>
20 </g>
21</svg>
assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg created+21
......@@ -0,0 +1,21 @@
1<svg width="64" height="32" viewBox="0 0 64 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="cloud" x1="7" y1="10" x2="57" y2="25" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#4EA4FF" stop-opacity="0.62"/>
5 <stop offset="0.52" stop-color="#696FEA" stop-opacity="0.72"/>
6 <stop offset="1" stop-color="#A85BEA" stop-opacity="0.58"/>
7 </linearGradient>
8 <radialGradient id="highlight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(30 14) rotate(13) scale(27 10)">
9 <stop stop-color="#C8D7FF" stop-opacity="0.4"/>
10 <stop offset="1" stop-color="#8C8EF2" stop-opacity="0"/>
11 </radialGradient>
12 <filter id="soft" x="-8" y="-8" width="80" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
13 <feGaussianBlur stdDeviation="2.5"/>
14 </filter>
15 </defs>
16
17 <g transform="translate(64 0) scale(-1 1)" filter="url(#soft)">
18 <path d="M7 23.5C7 18.9 10.8 15.2 15.5 15.2C16.9 10.7 21.1 7.5 26.1 7.5C31 7.5 35.2 10.5 36.7 14.8C38.5 13.2 40.9 12.2 43.5 12.2C49.1 12.2 53.7 16.4 54.1 21.8C57 22.2 59.2 24 60 26.5H8.2C7.4 25.7 7 24.7 7 23.5Z" fill="url(#cloud)"/>
19 <path d="M11 22.9C12.7 19.8 16 18 19.5 18.5C21 13.4 25.6 10 30.8 10.6C35 11 38.3 13.9 39.4 17.7C43.8 15.5 49 17.3 51.1 21.7C46.2 20.3 41.9 21 37.7 22.1C29.6 24.2 21.5 21.3 11 22.9Z" fill="url(#highlight)"/>
20 </g>
21</svg>
assets/nightly/app-icon.icon/Assets/text.svg created+3
......@@ -0,0 +1,3 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
3</svg>
assets/nightly/app-icon.icon/icon.json created+94
......@@ -0,0 +1,94 @@
1{
2 "fill": {
3 "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "glass": true,
10 "image-name": "text.svg",
11 "name": "Text",
12 "position": {
13 "scale": 8.5,
14 "translation-in-points": [0, 0]
15 }
16 },
17 {
18 "glass": false,
19 "image-name": "cloud-upper-right.svg",
20 "name": "Cloud Upper Right",
21 "opacity": 1,
22 "position": {
23 "scale": 15,
24 "translation-in-points": [387.9605131881942, -134.30064713259117]
25 }
26 },
27 {
28 "glass": false,
29 "image-name": "cloud-lower-left.svg",
30 "name": "Cloud Lower Left",
31 "position": {
32 "scale": 25,
33 "translation-in-points": [-309.63750000000005, 268.66077693836917]
34 }
35 },
36 {
37 "glass": true,
38 "hidden": false,
39 "image-name": "background.svg",
40 "name": "Background",
41 "position": {
42 "scale": 8.1,
43 "translation-in-points": [0, -2.171875]
44 }
45 }
46 ],
47 "shadow": {
48 "kind": "neutral",
49 "opacity": 0.6
50 },
51 "translucency": {
52 "enabled": true,
53 "value": 0.2
54 }
55 },
56 {
57 "layers": [],
58 "shadow": {
59 "kind": "neutral",
60 "opacity": 0.6
61 },
62 "translucency": {
63 "enabled": true,
64 "value": 0.2
65 }
66 },
67 {
68 "layers": [],
69 "shadow": {
70 "kind": "neutral",
71 "opacity": 0.6
72 },
73 "translucency": {
74 "enabled": true,
75 "value": 0.2
76 }
77 },
78 {
79 "layers": [],
80 "shadow": {
81 "kind": "neutral",
82 "opacity": 0.6
83 },
84 "translucency": {
85 "enabled": true,
86 "value": 0.2
87 }
88 }
89 ],
90 "supported-platforms": {
91 "circles": ["watchOS"],
92 "squares": "shared"
93 }
94}
assets/nightly/blueprint-ios-1024.png deleted
Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ
assets/nightly/blueprint-macos-1024.png deleted
Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ
assets/nightly/blueprint-universal-1024.png deleted
Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ
assets/nightly/blueprint-web-apple-touch-180.png deleted
Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ
assets/nightly/blueprint-web-favicon-16x16.png deleted
Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ
assets/nightly/blueprint-web-favicon-32x32.png deleted
Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ
assets/nightly/blueprint-web-favicon.ico deleted
Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ
assets/nightly/blueprint-windows.ico deleted
Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ
assets/nightly/nightly-ios-1024.png created
Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ
assets/nightly/nightly-macos-1024.png created
Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ
assets/nightly/nightly-universal-1024.png created
Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ
assets/nightly/nightly-web-apple-touch-180.png created
Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ
assets/nightly/nightly-web-favicon-16x16.png created
Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ
assets/nightly/nightly-web-favicon-32x32.png created
Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ
assets/nightly/nightly-web-favicon.ico created
Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ
assets/nightly/nightly-windows.ico created
Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ
assets/prod/app-icon.icon/Assets/text.svg created+3
......@@ -0,0 +1,3 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
assets/prod/app-icon.icon/icon.json created+31
......@@ -0,0 +1,31 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "image-name": "text.svg",
10 "name": "Text",
11 "position": {
12 "scale": 10,
13 "translation-in-points": [0, 0]
14 }
15 }
16 ],
17 "shadow": {
18 "kind": "neutral",
19 "opacity": 0.5
20 },
21 "translucency": {
22 "enabled": true,
23 "value": 0.5
24 }
25 }
26 ],
27 "supported-platforms": {
28 "circles": ["watchOS"],
29 "squares": "shared"
30 }
31}
assets/prod/black-ios-1024.png
Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ
assets/prod/black-macos-1024.png
Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ
assets/prod/black-universal-1024.png
Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ
assets/prod/t3-black-web-apple-touch-180.png
Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ
assets/prod/t3-black-web-favicon-16x16.png
Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ
assets/prod/t3-black-web-favicon-32x32.png
Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ
assets/prod/t3-black-web-favicon.ico
Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ
assets/prod/t3-black-windows.ico
Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ
package.json+2
......@@ -14,6 +14,8 @@
1414 "start:marketing": "vp run --filter @t3tools/marketing preview",
1515 "start:mock-update-server": "node scripts/mock-update-server.ts",
1616 "screenshots:mobile": "node scripts/mobile-showcase.ts",
17 "icons:export": "node scripts/export-brand-icons.ts",
18 "icons:check": "node scripts/export-brand-icons.ts --check",
1719 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
1820 "build:marketing": "vp run --filter @t3tools/marketing build",
1921 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
scripts/export-brand-icons.ts created+800
......@@ -0,0 +1,800 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import { Command, Flag } from "effect/unstable/cli";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
15
16import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
17import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts";
18
19const DESIGN_GENERATION = 26;
20const ICON_COMPOSER_EXECUTABLE_PARTS = [
21 "Contents",
22 "Applications",
23 "Icon Composer.app",
24 "Contents",
25 "Executables",
26 "ictool",
27] as const;
28const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [
29 "Icon Composer.app",
30 "Contents",
31 "Executables",
32 "ictool",
33] as const;
34const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
35
36const IconComposerVersion = Schema.Struct({
37 "bundle-version": Schema.NonEmptyString,
38 "short-bundle-version": Schema.NonEmptyString,
39});
40const decodeIconComposerVersion = Schema.decodeUnknownEffect(
41 Schema.fromJsonString(IconComposerVersion),
42);
43
44type IconPlatform = "iOS";
45
46interface VariantOutputs {
47 readonly ios: string;
48 readonly macos: string;
49 readonly universal: string;
50 readonly appleTouch: string;
51 readonly favicon16: string;
52 readonly favicon32: string;
53 readonly faviconIco: string;
54 readonly windowsIco: string;
55}
56
57interface IconVariant {
58 readonly label: string;
59 readonly source: string;
60 readonly outputs: VariantOutputs;
61}
62
63interface IconComposerTool {
64 readonly path: string;
65 readonly version: string;
66 readonly bundleVersion: string;
67 readonly supportsDesignGeneration: boolean;
68}
69
70interface CommandResult {
71 readonly stdout: string;
72 readonly stderr: string;
73 readonly exitCode: number;
74}
75
76export class IconExportFileSystemError extends Schema.TaggedErrorClass<IconExportFileSystemError>()(
77 "IconExportFileSystemError",
78 {
79 operation: Schema.Literals([
80 "resolve-repository-root",
81 "check-path",
82 "read-directory",
83 "read-file",
84 "make-directory",
85 "make-temp-directory",
86 "make-temp-file",
87 "write-file",
88 "rename-file",
89 ]),
90 path: Schema.String,
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`;
96 }
97}
98
99export class IconExportProcessError extends Schema.TaggedErrorClass<IconExportProcessError>()(
100 "IconExportProcessError",
101 {
102 operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]),
103 command: Schema.String,
104 argumentCount: NonNegativeInt,
105 cause: Schema.Defect(),
106 },
107) {
108 override get message(): string {
109 return `Icon export process operation '${this.operation}' failed for ${this.command}.`;
110 }
111}
112
113export class IconExportCommandFailedError extends Schema.TaggedErrorClass<IconExportCommandFailedError>()(
114 "IconExportCommandFailedError",
115 {
116 command: Schema.String,
117 argumentCount: NonNegativeInt,
118 exitCode: Schema.Int,
119 sourcePath: Schema.String,
120 size: Schema.Int,
121 stdout: Schema.optional(Schema.String),
122 stderr: Schema.optional(Schema.String),
123 },
124) {
125 override get message(): string {
126 return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`;
127 }
128}
129
130export class IconExportToolResolutionError extends Schema.TaggedErrorClass<IconExportToolResolutionError>()(
131 "IconExportToolResolutionError",
132 {
133 reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]),
134 designGeneration: Schema.Int,
135 toolPath: Schema.optional(Schema.String),
136 version: Schema.optional(Schema.String),
137 },
138) {
139 override get message(): string {
140 switch (this.reason) {
141 case "configured-invalid":
142 return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`;
143 case "configured-outdated":
144 return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`;
145 case "not-found":
146 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.`;
147 }
148 }
149}
150
151export class IconExportSourceMissingError extends Schema.TaggedErrorClass<IconExportSourceMissingError>()(
152 "IconExportSourceMissingError",
153 {
154 sourcePath: Schema.String,
155 },
156) {
157 override get message(): string {
158 return `Missing Icon Composer source project: ${this.sourcePath}`;
159 }
160}
161
162export class IconExportRenditionError extends Schema.TaggedErrorClass<IconExportRenditionError>()(
163 "IconExportRenditionError",
164 {
165 sourcePath: Schema.String,
166 outputPath: Schema.String,
167 expectedSize: Schema.Int,
168 actualWidth: Schema.optional(Schema.Int),
169 actualHeight: Schema.optional(Schema.Int),
170 cause: Schema.optional(Schema.Defect()),
171 },
172) {
173 override get message(): string {
174 const actual =
175 this.actualWidth === undefined || this.actualHeight === undefined
176 ? "an invalid PNG"
177 : `${this.actualWidth}x${this.actualHeight}`;
178 return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`;
179 }
180}
181
182export class IconExportEncodingError extends Schema.TaggedErrorClass<IconExportEncodingError>()(
183 "IconExportEncodingError",
184 {
185 variant: Schema.String,
186 cause: Schema.Defect(),
187 },
188) {
189 override get message(): string {
190 return `Failed to encode ICO renditions for the ${this.variant} icon.`;
191 }
192}
193
194export class IconExportAssetsStaleError extends Schema.TaggedErrorClass<IconExportAssetsStaleError>()(
195 "IconExportAssetsStaleError",
196 {
197 paths: Schema.Array(Schema.String),
198 },
199) {
200 override get message(): string {
201 return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`;
202 }
203}
204
205const ICON_VARIANTS = [
206 {
207 label: "development",
208 source: BRAND_ASSET_PATHS.developmentIconComposerProject,
209 outputs: {
210 ios: BRAND_ASSET_PATHS.developmentIosIconPng,
211 macos: BRAND_ASSET_PATHS.developmentDesktopIconPng,
212 universal: BRAND_ASSET_PATHS.developmentUniversalIconPng,
213 appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
214 favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
215 favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
216 faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
217 windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco,
218 },
219 },
220 {
221 label: "preview",
222 source: BRAND_ASSET_PATHS.nightlyIconComposerProject,
223 outputs: {
224 ios: BRAND_ASSET_PATHS.nightlyIosIconPng,
225 macos: BRAND_ASSET_PATHS.nightlyMacIconPng,
226 universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
227 appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng,
228 favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png,
229 favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png,
230 faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
231 windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
232 },
233 },
234 {
235 label: "production",
236 source: BRAND_ASSET_PATHS.productionIconComposerProject,
237 outputs: {
238 ios: BRAND_ASSET_PATHS.productionIosIconPng,
239 macos: BRAND_ASSET_PATHS.productionMacIconPng,
240 universal: BRAND_ASSET_PATHS.productionLinuxIconPng,
241 appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
242 favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png,
243 favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png,
244 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
245 windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
246 },
247 },
248] as const satisfies ReadonlyArray<IconVariant>;
249
250const MACOS_EXPORT_CODEX_PROMPT = [
251 "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.",
252 "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:",
253 ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
254 "Do not resize, composite, or otherwise post-process the exported PNGs.",
255 "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.",
256];
257
258const RepositoryRoot = Effect.service(Path.Path).pipe(
259 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
260 Effect.mapError(
261 (cause) =>
262 new IconExportFileSystemError({
263 operation: "resolve-repository-root",
264 path: new URL("..", import.meta.url).pathname,
265 cause,
266 }),
267 ),
268);
269
270const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>) =>
271 stream.pipe(
272 Stream.decodeText(),
273 Stream.runFold(
274 () => "",
275 (acc, chunk) => acc + chunk,
276 ),
277 );
278
279const runCommand = Effect.fn("iconExport.runCommand")(function* (
280 command: string,
281 args: ReadonlyArray<string>,
282) {
283 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
284 const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe(
285 Effect.mapError(
286 (cause) =>
287 new IconExportProcessError({
288 operation: "spawn",
289 command,
290 argumentCount: args.length,
291 cause,
292 }),
293 ),
294 );
295 const [stdout, stderr, exitCode] = yield* Effect.all(
296 [
297 collectStreamAsString(child.stdout).pipe(
298 Effect.mapError(
299 (cause) =>
300 new IconExportProcessError({
301 operation: "collect-stdout",
302 command,
303 argumentCount: args.length,
304 cause,
305 }),
306 ),
307 ),
308 collectStreamAsString(child.stderr).pipe(
309 Effect.mapError(
310 (cause) =>
311 new IconExportProcessError({
312 operation: "collect-stderr",
313 command,
314 argumentCount: args.length,
315 cause,
316 }),
317 ),
318 ),
319 child.exitCode.pipe(
320 Effect.map(Number),
321 Effect.mapError(
322 (cause) =>
323 new IconExportProcessError({
324 operation: "wait-for-exit",
325 command,
326 argumentCount: args.length,
327 cause,
328 }),
329 ),
330 ),
331 ],
332 { concurrency: "unbounded" },
333 );
334
335 return { stdout, stderr, exitCode } satisfies CommandResult;
336});
337
338const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) =>
339 path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1));
340
341const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")(
342 function* () {
343 const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option);
344 return Option.flatMap(result, (output) => {
345 const developerDirectory = output.stdout.trim();
346 return output.exitCode === 0 && developerDirectory.length > 0
347 ? Option.some(developerDirectory)
348 : Option.none();
349 });
350 },
351);
352
353const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* (
354 directory: string,
355) {
356 const fs = yield* FileSystem.FileSystem;
357 const path = yield* Path.Path;
358 const entries = yield* fs.readDirectory(directory).pipe(
359 Effect.mapError(
360 (cause) =>
361 new IconExportFileSystemError({
362 operation: "read-directory",
363 path: directory,
364 cause,
365 }),
366 ),
367 Effect.orElseSucceed(() => []),
368 );
369 return entries
370 .filter((entry) => /^Xcode.*\.app$/.test(entry))
371 .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS));
372});
373
374const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* (
375 candidate: string,
376) {
377 const fs = yield* FileSystem.FileSystem;
378 const exists = yield* fs.exists(candidate).pipe(
379 Effect.mapError(
380 (cause) =>
381 new IconExportFileSystemError({
382 operation: "check-path",
383 path: candidate,
384 cause,
385 }),
386 ),
387 Effect.orElseSucceed(() => false),
388 );
389 if (!exists) return Option.none<IconComposerTool>();
390
391 const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option);
392 if (Option.isNone(result) || result.value.exitCode !== 0) {
393 return Option.none<IconComposerTool>();
394 }
395
396 const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option);
397 if (Option.isNone(version)) return Option.none<IconComposerTool>();
398
399 const bundleVersion = version.value["bundle-version"];
400 const shortVersion = version.value["short-bundle-version"];
401 return Option.some({
402 path: candidate,
403 version: `${shortVersion} (${bundleVersion})`,
404 bundleVersion,
405 supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2,
406 });
407});
408
409const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () {
410 const path = yield* Path.Path;
411 const environment = yield* HostProcessEnvironment;
412 const configuredTool = environment.ICON_COMPOSER_TOOL?.trim();
413 if (configuredTool) {
414 const tool = yield* probeIconComposerTool(configuredTool);
415 if (Option.isNone(tool)) {
416 return yield* new IconExportToolResolutionError({
417 reason: "configured-invalid",
418 designGeneration: DESIGN_GENERATION,
419 toolPath: configuredTool,
420 });
421 }
422 if (!tool.value.supportsDesignGeneration) {
423 return yield* new IconExportToolResolutionError({
424 reason: "configured-outdated",
425 designGeneration: DESIGN_GENERATION,
426 toolPath: configuredTool,
427 version: tool.value.version,
428 });
429 }
430 return tool.value;
431 }
432
433 const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory();
434 const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim();
435 const homeDirectory = environment.HOME?.trim();
436 const searchDirectories = [
437 "/Applications",
438 ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []),
439 ];
440 const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, {
441 concurrency: "unbounded",
442 });
443 const candidates = new Set<string>([
444 ...(configuredDeveloperDirectory
445 ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)]
446 : []),
447 ...Option.match(selectedDeveloperDirectory, {
448 onNone: () => [],
449 onSome: (developerDirectory) => [
450 iconComposerToolFromDeveloperDirectory(developerDirectory, path),
451 ],
452 }),
453 path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS),
454 ...(homeDirectory
455 ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)]
456 : []),
457 ...xcodeCandidates.flat(),
458 ]);
459 const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, {
460 concurrency: "unbounded",
461 });
462 const compatibleTools = probed
463 .filter(Option.isSome)
464 .map((tool) => tool.value)
465 .filter((tool) => tool.supportsDesignGeneration)
466 .sort((left, right) =>
467 right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }),
468 );
469 const newestTool = compatibleTools[0];
470 if (newestTool) return newestTool;
471
472 return yield* new IconExportToolResolutionError({
473 reason: "not-found",
474 designGeneration: DESIGN_GENERATION,
475 });
476});
477
478const renderIcon = Effect.fn("iconExport.renderIcon")(function* (
479 toolPath: string,
480 sourcePath: string,
481 outputPath: string,
482 platform: IconPlatform,
483 size: number,
484) {
485 const fs = yield* FileSystem.FileSystem;
486 const args = [
487 sourcePath,
488 "--export-image",
489 "--output-file",
490 outputPath,
491 "--platform",
492 platform,
493 "--rendition",
494 "Default",
495 "--width",
496 String(size),
497 "--height",
498 String(size),
499 "--scale",
500 "1",
501 "--design-generation",
502 String(DESIGN_GENERATION),
503 ];
504 const result = yield* runCommand(toolPath, args);
505 if (result.exitCode !== 0) {
506 return yield* new IconExportCommandFailedError({
507 command: toolPath,
508 argumentCount: args.length,
509 exitCode: result.exitCode,
510 sourcePath,
511 size,
512 ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}),
513 ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}),
514 });
515 }
516
517 const contents = yield* fs.readFile(outputPath).pipe(
518 Effect.mapError(
519 (cause) =>
520 new IconExportFileSystemError({
521 operation: "read-file",
522 path: outputPath,
523 cause,
524 }),
525 ),
526 );
527 const buffer = Buffer.from(contents);
528 const dimensions = yield* Effect.try({
529 try: () => readPngDimensions(buffer),
530 catch: (cause) =>
531 new IconExportRenditionError({
532 sourcePath,
533 outputPath,
534 expectedSize: size,
535 cause,
536 }),
537 });
538 if (dimensions.width !== size || dimensions.height !== size) {
539 return yield* new IconExportRenditionError({
540 sourcePath,
541 outputPath,
542 expectedSize: size,
543 actualWidth: dimensions.width,
544 actualHeight: dimensions.height,
545 });
546 }
547 return buffer;
548});
549
550const renderVariant = Effect.fn("iconExport.renderVariant")(function* (
551 toolPath: string,
552 repositoryRoot: string,
553 temporaryDirectory: string,
554 variant: IconVariant,
555) {
556 const fs = yield* FileSystem.FileSystem;
557 const path = yield* Path.Path;
558 const sourcePath = path.join(repositoryRoot, variant.source);
559 const sourceExists = yield* fs.exists(sourcePath).pipe(
560 Effect.mapError(
561 (cause) =>
562 new IconExportFileSystemError({
563 operation: "check-path",
564 path: sourcePath,
565 cause,
566 }),
567 ),
568 );
569 if (!sourceExists) {
570 return yield* new IconExportSourceMissingError({ sourcePath: variant.source });
571 }
572
573 const renditionCache = new Map<string, Buffer>();
574 const render = Effect.fn("iconExport.renderVariant.rendition")(function* (
575 platform: IconPlatform,
576 size: number,
577 ) {
578 const cacheKey = `${platform}-${size}`;
579 const cached = renditionCache.get(cacheKey);
580 if (cached) return cached;
581
582 const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`);
583 const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size);
584 renditionCache.set(cacheKey, contents);
585 return contents;
586 });
587
588 const ios = yield* render("iOS", 1024);
589 const icoRenditions = yield* Effect.forEach(
590 WINDOWS_ICON_SIZES,
591 (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))),
592 { concurrency: 1 },
593 );
594 const ico = yield* Effect.try({
595 try: () => encodePngIco(icoRenditions),
596 catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }),
597 });
598
599 return new Map<string, Buffer>([
600 [variant.outputs.ios, ios],
601 [variant.outputs.universal, ios],
602 [variant.outputs.appleTouch, yield* render("iOS", 180)],
603 [variant.outputs.favicon16, yield* render("iOS", 16)],
604 [variant.outputs.favicon32, yield* render("iOS", 32)],
605 [variant.outputs.faviconIco, ico],
606 [variant.outputs.windowsIco, ico],
607 ]);
608});
609
610const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")(
611 function* () {
612 yield* Console.warn(
613 [
614 "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.",
615 "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:",
616 ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`),
617 "See assets/README.md for the complete workflow.",
618 "",
619 "Copy/paste this prompt into Codex to perform the native exports:",
620 "---",
621 ...MACOS_EXPORT_CODEX_PROMPT,
622 "---",
623 ].join("\n"),
624 );
625 },
626);
627
628const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* (
629 repositoryRoot: string,
630 relativePath: string,
631 contents: Buffer,
632) {
633 const fs = yield* FileSystem.FileSystem;
634 const path = yield* Path.Path;
635 const targetPath = path.join(repositoryRoot, relativePath);
636 const targetDirectory = path.dirname(targetPath);
637 yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe(
638 Effect.mapError(
639 (cause) =>
640 new IconExportFileSystemError({
641 operation: "make-directory",
642 path: targetDirectory,
643 cause,
644 }),
645 ),
646 );
647 const temporaryPath = yield* fs
648 .makeTempFileScoped({
649 directory: targetDirectory,
650 prefix: ".t3-icon-export-",
651 suffix: ".tmp",
652 })
653 .pipe(
654 Effect.mapError(
655 (cause) =>
656 new IconExportFileSystemError({
657 operation: "make-temp-file",
658 path: targetDirectory,
659 cause,
660 }),
661 ),
662 );
663 yield* fs.writeFile(temporaryPath, contents).pipe(
664 Effect.mapError(
665 (cause) =>
666 new IconExportFileSystemError({
667 operation: "write-file",
668 path: temporaryPath,
669 cause,
670 }),
671 ),
672 );
673 yield* fs.rename(temporaryPath, targetPath).pipe(
674 Effect.mapError(
675 (cause) =>
676 new IconExportFileSystemError({
677 operation: "rename-file",
678 path: targetPath,
679 cause,
680 }),
681 ),
682 );
683});
684
685const isCurrent = Effect.fn("iconExport.isCurrent")(function* (
686 repositoryRoot: string,
687 relativePath: string,
688 expected: Buffer,
689) {
690 const fs = yield* FileSystem.FileSystem;
691 const path = yield* Path.Path;
692 const targetPath = path.join(repositoryRoot, relativePath);
693 const exists = yield* fs.exists(targetPath).pipe(
694 Effect.mapError(
695 (cause) =>
696 new IconExportFileSystemError({
697 operation: "check-path",
698 path: targetPath,
699 cause,
700 }),
701 ),
702 );
703 if (!exists) return false;
704
705 const actual = yield* fs.readFile(targetPath).pipe(
706 Effect.mapError(
707 (cause) =>
708 new IconExportFileSystemError({
709 operation: "read-file",
710 path: targetPath,
711 cause,
712 }),
713 ),
714 );
715 return Buffer.from(actual).equals(expected);
716});
717
718export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) {
719 const fs = yield* FileSystem.FileSystem;
720 const repositoryRoot = yield* RepositoryRoot;
721 const tool = yield* resolveIconComposerTool();
722 const temporaryDirectory = yield* fs
723 .makeTempDirectoryScoped({
724 prefix: "t3-icon-export-",
725 })
726 .pipe(
727 Effect.mapError(
728 (cause) =>
729 new IconExportFileSystemError({
730 operation: "make-temp-directory",
731 path: "system temporary directory",
732 cause,
733 }),
734 ),
735 );
736 yield* Console.log(
737 `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`,
738 );
739
740 const generated = new Map<string, Buffer>();
741 for (const variant of ICON_VARIANTS) {
742 yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`);
743 const variantAssets = yield* renderVariant(
744 tool.path,
745 repositoryRoot,
746 temporaryDirectory,
747 variant,
748 );
749 for (const [relativePath, contents] of variantAssets) {
750 generated.set(relativePath, contents);
751 }
752 }
753
754 if (checkOnly) {
755 const stale = yield* Effect.filter(
756 [...generated.entries()],
757 ([relativePath, contents]) =>
758 isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)),
759 { concurrency: "unbounded" },
760 );
761 if (stale.length > 0) {
762 return yield* new IconExportAssetsStaleError({
763 paths: stale.map(([relativePath]) => relativePath),
764 });
765 }
766 yield* Console.log(`All ${generated.size} generated icon assets are current.`);
767 yield* logManualMacOsExportInstructions();
768 return;
769 }
770
771 yield* Effect.forEach(
772 generated,
773 ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents),
774 { concurrency: 1, discard: true },
775 );
776 yield* Console.log(`Updated ${generated.size} generated icon assets.`);
777 yield* logManualMacOsExportInstructions();
778});
779
780export const exportBrandIconsCommand = Command.make(
781 "export-brand-icons",
782 {
783 check: Flag.boolean("check").pipe(
784 Flag.withDescription("Verify generated icon assets without modifying files."),
785 Flag.withDefault(false),
786 ),
787 },
788 ({ check }) => exportBrandIcons(check).pipe(Effect.scoped),
789).pipe(
790 Command.withDescription(
791 "Export development, preview, and production assets from Icon Composer projects.",
792 ),
793);
794
795if (import.meta.main) {
796 Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe(
797 Effect.provide(NodeServices.layer),
798 NodeRuntime.runMain,
799 );
800}
scripts/lib/brand-assets.test.ts+15
......@@ -55,4 +55,19 @@ describe("brand-assets", () => {
5555 expect(resolveWebAssetBrandForChannel("latest")).toBe("production");
5656 expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly");
5757 });
58
59 it("keeps development, nightly, and production icon families separate", () => {
60 expect([
61 BRAND_ASSET_PATHS.developmentIconComposerProject,
62 BRAND_ASSET_PATHS.nightlyIconComposerProject,
63 BRAND_ASSET_PATHS.productionIconComposerProject,
64 ]).toEqual([
65 "assets/dev/app-icon.icon",
66 "assets/nightly/app-icon.icon",
67 "assets/prod/app-icon.icon",
68 ]);
69 expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/);
70 expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/);
71 expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/);
72 });
5873});
scripts/lib/brand-assets.ts+15-7
......@@ -1,4 +1,10 @@
11export const BRAND_ASSET_PATHS = {
2 developmentIconComposerProject: "assets/dev/app-icon.icon",
3 developmentIosIconPng: "assets/dev/blueprint-ios-1024.png",
4 developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png",
5
6 productionIconComposerProject: "assets/prod/app-icon.icon",
7 productionIosIconPng: "assets/prod/black-ios-1024.png",
28 productionMacIconPng: "assets/prod/black-macos-1024.png",
39 productionLinuxIconPng: "assets/prod/black-universal-1024.png",
410 productionWindowsIconIco: "assets/prod/t3-black-windows.ico",
......@@ -7,13 +13,15 @@ export const BRAND_ASSET_PATHS = {
713 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
814 productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",
915
10 nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
11 nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
12 nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
13 nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico",
14 nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png",
15 nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png",
16 nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png",
16 nightlyIconComposerProject: "assets/nightly/app-icon.icon",
17 nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png",
18 nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png",
19 nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png",
20 nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico",
21 nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico",
22 nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png",
23 nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png",
24 nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png",
1725
1826 developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
1927 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
scripts/lib/icon-export.test.ts created+47
......@@ -0,0 +1,47 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { encodePngIco, readPngDimensions } from "./icon-export.ts";
4
5const pngHeader = (width: number, height: number) => {
6 const contents = Buffer.alloc(24);
7 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents);
8 contents.write("IHDR", 12, "ascii");
9 contents.writeUInt32BE(width, 16);
10 contents.writeUInt32BE(height, 20);
11 return contents;
12};
13
14describe("icon export", () => {
15 it("reads dimensions from a PNG IHDR chunk", () => {
16 assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 });
17 });
18
19 it("encodes PNG renditions into an ICO directory", () => {
20 const small = pngHeader(16, 16);
21 const large = pngHeader(256, 256);
22 const ico = encodePngIco([
23 { size: 16, contents: small },
24 { size: 256, contents: large },
25 ]);
26
27 assert.equal(ico.readUInt16LE(2), 1);
28 assert.equal(ico.readUInt16LE(4), 2);
29 assert.equal(ico.readUInt8(6), 16);
30 assert.equal(ico.readUInt8(22), 0);
31 assert.equal(ico.readUInt32LE(18), 38);
32 assert.equal(ico.readUInt32LE(34), 38 + small.length);
33 assert.deepEqual(ico.subarray(38, 38 + small.length), small);
34 assert.deepEqual(ico.subarray(38 + small.length), large);
35 });
36
37 it("rejects duplicate ICO rendition sizes", () => {
38 assert.throws(
39 () =>
40 encodePngIco([
41 { size: 32, contents: pngHeader(32, 32) },
42 { size: 32, contents: pngHeader(32, 32) },
43 ]),
44 /provided more than once/,
45 );
46 });
47});
scripts/lib/icon-export.ts created+72
......@@ -0,0 +1,72 @@
1const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
2
3export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const;
4
5export interface PngIconImage {
6 readonly size: number;
7 readonly contents: Buffer;
8}
9
10export function readPngDimensions(contents: Buffer): {
11 readonly width: number;
12 readonly height: number;
13} {
14 if (
15 contents.length < 24 ||
16 !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) ||
17 contents.toString("ascii", 12, 16) !== "IHDR"
18 ) {
19 throw new Error("Icon Composer produced an invalid PNG.");
20 }
21
22 return {
23 width: contents.readUInt32BE(16),
24 height: contents.readUInt32BE(20),
25 };
26}
27
28/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */
29export function encodePngIco(images: ReadonlyArray<PngIconImage>): Buffer {
30 if (images.length === 0) {
31 throw new Error("An ICO file requires at least one PNG rendition.");
32 }
33
34 const seenSizes = new Set<number>();
35 for (const image of images) {
36 if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) {
37 throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`);
38 }
39 if (seenSizes.has(image.size)) {
40 throw new Error(`ICO rendition size ${image.size} was provided more than once.`);
41 }
42 if (image.contents.length === 0) {
43 throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`);
44 }
45 seenSizes.add(image.size);
46 }
47
48 const headerSize = 6;
49 const directoryEntrySize = 16;
50 const directorySize = directoryEntrySize * images.length;
51 const header = Buffer.alloc(headerSize + directorySize);
52 header.writeUInt16LE(0, 0);
53 header.writeUInt16LE(1, 2);
54 header.writeUInt16LE(images.length, 4);
55
56 let imageOffset = header.length;
57 images.forEach((image, index) => {
58 const entryOffset = headerSize + index * directoryEntrySize;
59 const encodedSize = image.size === 256 ? 0 : image.size;
60 header.writeUInt8(encodedSize, entryOffset);
61 header.writeUInt8(encodedSize, entryOffset + 1);
62 header.writeUInt8(0, entryOffset + 2);
63 header.writeUInt8(0, entryOffset + 3);
64 header.writeUInt16LE(1, entryOffset + 4);
65 header.writeUInt16LE(32, entryOffset + 6);
66 header.writeUInt32LE(image.contents.length, entryOffset + 8);
67 header.writeUInt32LE(imageOffset, entryOffset + 12);
68 imageOffset += image.contents.length;
69 });
70
71 return Buffer.concat([header, ...images.map((image) => image.contents)]);
72}