| author | |
| committer | |
| log | 6e9987cf17eef56303a7e5033aa152a92b234d65 |
| tree | da6b221e4b1685833f8d73f0f9d1ce06f9b9079e |
| parent | 8b5469863ae1dd696e696de30240ec3da607962d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | import type { ExpoConfig } from "expo/config"; |
| 2 | 2 | |
| 3 | import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; | |
| 3 | 4 | import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; |
| 4 | 5 | |
| 5 | 6 | type AppVariant = "development" | "preview" | "production"; |
| ... | ... | @@ -13,6 +14,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; |
| 13 | 14 | const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); |
| 14 | 15 | const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; |
| 15 | 16 | |
| 17 | const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; | |
| 18 | ||
| 16 | 19 | if ( |
| 17 | 20 | isIosPersonalTeamBuild && |
| 18 | 21 | (!personalTeamBundleIdentifier || |
| ... | ... | @@ -24,20 +27,31 @@ if ( |
| 24 | 27 | } |
| 25 | 28 | |
| 26 | 29 | const 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), | |
| 31 | 34 | androidAdaptiveBackgroundColor: "#00639B", |
| 32 | 35 | androidMonochromeIcon: "./assets/android-icon-mark.png", |
| 33 | 36 | androidNotificationIcon: "./assets/android-notification-icon.png", |
| 34 | 37 | androidNotificationColor: "#00639B", |
| 35 | 38 | } as const; |
| 36 | 39 | |
| 40 | const 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 | ||
| 37 | 51 | const 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), | |
| 41 | 55 | androidAdaptiveForeground: "./assets/android-icon-mark.png", |
| 42 | 56 | androidAdaptiveBackgroundColor: "#000000", |
| 43 | 57 | androidMonochromeIcon: "./assets/android-icon-mark.png", |
| ... | ... | @@ -60,7 +74,7 @@ const VARIANT_CONFIG = { |
| 60 | 74 | iosBundleIdentifier: "com.t3tools.t3code.preview", |
| 61 | 75 | androidPackage: "com.t3tools.t3code.preview", |
| 62 | 76 | relyingParty: "clerk.t3.codes", |
| 63 | assets: RELEASE_ASSETS, | |
| 77 | assets: PREVIEW_ASSETS, | |
| 64 | 78 | }, |
| 65 | 79 | production: { |
| 66 | 80 | 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 @@ |
| 1 | import { Image, View } from "react-native"; | |
| 1 | import Constants from "expo-constants"; | |
| 2 | import { Image } from "expo-image"; | |
| 3 | import { View } from "react-native"; | |
| 2 | 4 | |
| 3 | 5 | import { AppText as Text } from "./AppText"; |
| 4 | 6 | |
| 5 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); | |
| 7 | const appVariant = Constants.expoConfig?.extra?.appVariant; | |
| 8 | const 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"); | |
| 14 | const DEFAULT_STAGE_LABEL = | |
| 15 | appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; | |
| 6 | 16 | |
| 7 | 17 | export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { |
| 8 | 18 | const compact = props.compact ?? false; |
| 9 | 19 | const iconSize = compact ? 32 : 44; |
| 10 | const stageLabel = props.stageLabel ?? "Alpha"; | |
| 20 | const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; | |
| 11 | 21 | |
| 12 | 22 | return ( |
| 13 | 23 | <View className="flex-row items-center gap-3"> |
assets/README.md created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | # Brand icons | |
| 2 | ||
| 3 | The 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 | ||
| 9 | 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. | |
| 10 | ||
| 11 | 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. | |
| 12 | ||
| 13 | 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. | |
| 14 | ||
| 15 | ## macOS exports | |
| 16 | ||
| 17 | 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. | |
| 18 | ||
| 19 | After 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 | ||
| 26 | Save 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 | ||
| 32 | 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. | |
| 33 | ||
| 34 | To have Codex perform the native exports, paste this prompt into a task opened at the repository root: | |
| 35 | ||
| 36 | ```text | |
| 37 | Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository. | |
| 38 | ||
| 39 | For 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 | ||
| 45 | Do not resize, composite, or otherwise post-process the exported PNGs. | |
| 46 | ||
| 47 | 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. | |
| 48 | ``` | |
| 49 | ||
| 50 | Do 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 @@ |
| 14 | 14 | "start:marketing": "vp run --filter @t3tools/marketing preview", |
| 15 | 15 | "start:mock-update-server": "node scripts/mock-update-server.ts", |
| 16 | 16 | "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", | |
| 17 | 19 | "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", |
| 18 | 20 | "build:marketing": "vp run --filter @t3tools/marketing build", |
| 19 | 21 | "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 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 6 | import * as Console from "effect/Console"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import { Command, Flag } from "effect/unstable/cli"; | |
| 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 15 | ||
| 16 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | |
| 17 | import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts"; | |
| 18 | ||
| 19 | const DESIGN_GENERATION = 26; | |
| 20 | const ICON_COMPOSER_EXECUTABLE_PARTS = [ | |
| 21 | "Contents", | |
| 22 | "Applications", | |
| 23 | "Icon Composer.app", | |
| 24 | "Contents", | |
| 25 | "Executables", | |
| 26 | "ictool", | |
| 27 | ] as const; | |
| 28 | const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [ | |
| 29 | "Icon Composer.app", | |
| 30 | "Contents", | |
| 31 | "Executables", | |
| 32 | "ictool", | |
| 33 | ] as const; | |
| 34 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | |
| 35 | ||
| 36 | const IconComposerVersion = Schema.Struct({ | |
| 37 | "bundle-version": Schema.NonEmptyString, | |
| 38 | "short-bundle-version": Schema.NonEmptyString, | |
| 39 | }); | |
| 40 | const decodeIconComposerVersion = Schema.decodeUnknownEffect( | |
| 41 | Schema.fromJsonString(IconComposerVersion), | |
| 42 | ); | |
| 43 | ||
| 44 | type IconPlatform = "iOS"; | |
| 45 | ||
| 46 | interface 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 | ||
| 57 | interface IconVariant { | |
| 58 | readonly label: string; | |
| 59 | readonly source: string; | |
| 60 | readonly outputs: VariantOutputs; | |
| 61 | } | |
| 62 | ||
| 63 | interface IconComposerTool { | |
| 64 | readonly path: string; | |
| 65 | readonly version: string; | |
| 66 | readonly bundleVersion: string; | |
| 67 | readonly supportsDesignGeneration: boolean; | |
| 68 | } | |
| 69 | ||
| 70 | interface CommandResult { | |
| 71 | readonly stdout: string; | |
| 72 | readonly stderr: string; | |
| 73 | readonly exitCode: number; | |
| 74 | } | |
| 75 | ||
| 76 | export 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 | ||
| 99 | export 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 | ||
| 113 | export 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 | ||
| 130 | export 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 | ||
| 151 | export 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 | ||
| 162 | export 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 | ||
| 182 | export 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 | ||
| 194 | export 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 | ||
| 205 | const 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 | ||
| 250 | const 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 | ||
| 258 | const 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 | ||
| 270 | const 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 | ||
| 279 | const 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 | ||
| 338 | const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) => | |
| 339 | path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1)); | |
| 340 | ||
| 341 | const 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 | ||
| 353 | const 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 | ||
| 374 | const 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 | ||
| 409 | const 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 | ||
| 478 | const 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 | ||
| 550 | const 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 | ||
| 610 | const 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 | ||
| 628 | const 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 | ||
| 685 | const 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 | ||
| 718 | export 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 | ||
| 780 | export 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 | ||
| 795 | if (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", () => { |
| 55 | 55 | expect(resolveWebAssetBrandForChannel("latest")).toBe("production"); |
| 56 | 56 | expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); |
| 57 | 57 | }); |
| 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 | }); | |
| 58 | 73 | }); |
scripts/lib/brand-assets.ts+15-7| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 1 | export 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", | |
| 2 | 8 | productionMacIconPng: "assets/prod/black-macos-1024.png", |
| 3 | 9 | productionLinuxIconPng: "assets/prod/black-universal-1024.png", |
| 4 | 10 | productionWindowsIconIco: "assets/prod/t3-black-windows.ico", |
| ... | ... | @@ -7,13 +13,15 @@ export const BRAND_ASSET_PATHS = { |
| 7 | 13 | productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", |
| 8 | 14 | productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", |
| 9 | 15 | |
| 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", | |
| 17 | 25 | |
| 18 | 26 | developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", |
| 19 | 27 | developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", |
scripts/lib/icon-export.test.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { encodePngIco, readPngDimensions } from "./icon-export.ts"; | |
| 4 | ||
| 5 | const 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 | ||
| 14 | describe("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 @@ |
| 1 | const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); | |
| 2 | ||
| 3 | export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; | |
| 4 | ||
| 5 | export interface PngIconImage { | |
| 6 | readonly size: number; | |
| 7 | readonly contents: Buffer; | |
| 8 | } | |
| 9 | ||
| 10 | export 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. */ | |
| 29 | export 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 | } |