Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
# Changelog

## [Unreleased]
- feat(icons): the mark is sized to Apple's proportions instead of a hardcoded
0.6 of the shorter side. The icon is normalized to the square its visible
artwork occupies before scaling, so the number means "the mark covers N% of
the canvas" no matter how much transparent margin the source carries. New
`--ios-icon-scale` (default 0.8, Apple's icon grid) and `--tv-icon-scale`
(default 0.75, inside Apple's 10-15% parallax safe margin). One content box is
measured per run and shared by every surface and every parallax layer, which
keeps the layers registered.
- feat(config): `--icon` is optional when every icon layer carries its own art.
The flat icon is assembled from that art back to front and used for the iOS
appiconset, Top Shelf images, splash logo, and `icon.png`, so a project with
parallax layers no longer maintains a hand-flattened third copy of them.

## [1.4.0] - 2026-08-05
- feat(cli): full config parity, preview.html, and DX pass (#7)
Expand Down
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Every option, on every surface. **Config key** is the dotted path in `tvos-asset

| Option | Config key | Plugin | Type | Default | Description |
|---|---|---|---|---|---|
| `--icon <path>` | `inputs.iconImage` | `icon` | path | **required** | Icon PNG or SVG with a transparent background. |
| `--icon <path>` | `inputs.iconImage` | `icon` | path | **required\*** | Icon PNG or SVG with a transparent background. \*Optional when `--layer-front` and `--layer-middle` are both given: the icon is then assembled from them. See [Assembling the icon from layer art](#assembling-the-icon-from-layer-art). |
| `--background <path>` | `inputs.backgroundImage` | `background` | path | **required** | Background PNG or SVG. |
| `--color <hex>` | `inputs.backgroundColor` | `color` | `#RRGGBB` | **required** | Splash background color, light mode. |
| `--dark-color <hex>` | `inputs.darkBackgroundColor` | `darkColor` | `#RRGGBB` | auto | Dark mode splash background. Auto-darkened from `--color` (50% HSL lightness reduction). |
Expand All @@ -80,6 +80,8 @@ Every option, on every surface. **Config key** is the dotted path in `tvos-asset
| `--set brandAssets.appIconSmall.size.width=` | `brandAssets.appIconSmall.size` | via `config` | `{width,height}` | `400x240` | Base size in points, multiplied by each scale. |
| `--set brandAssets.appIconSmall.scales=` | `brandAssets.appIconSmall.scales` | via `config` | string[] | `1x,2x` | Scale factors to generate. |
| `--set brandAssets.appIconLarge.*=` | `brandAssets.appIconLarge.*` | via `config` | same four keys | `App Icon - App Store`, `1280x768`, `1x` | App Store imagestack. Same structure as `appIconSmall`. |
| `--ios-icon-scale <0-1>` | `iosIcon.iconScale` | `iosIconScale` | number | `0.8` | How much of the 1024 canvas the mark covers. Apple's icon grid centres the primary shape at about 80%. See [Sizing the mark](#sizing-the-mark). |
| `--tv-icon-scale <0-1>` | `brandAssets.iconScale` | `tvIconScale` | number | `0.75` | How much of the shorter tvOS side the mark covers, on both imagestacks and both Top Shelf images. Apple asks for a 10-15% safe margin per layer. |
| `--layer-front`, `--layer-middle`, `--layer-back` | `brandAssets.<stack>.layers.<layer>.imagePath` | `layers` | path | icon, icon, background | Custom parallax art per layer. The CLI flags apply to both imagestacks. See [Per-layer parallax art](#per-layer-parallax-art). |
| `--set brandAssets.<stack>.layers.<layer>.source=` | `brandAssets.<stack>.layers.<layer>.source` | via `config` | `icon` \| `background` | front/middle `icon`, back `background` | How the layer renders: `icon` is centered on transparency, `background` is an opaque cover fill. |
| `--no-top-shelf` | `brandAssets.topShelfImage(Wide).enabled` | via `config` | boolean | `true` | Both Top Shelf imagesets on/off. |
Expand Down Expand Up @@ -143,6 +145,8 @@ Every capability, and how to reach it from each surface. "via `config`" means th
| Home screen icon size | `--set brandAssets.appIconSmall.size.width=` | `brandAssets.appIconSmall.size` | via `config` |
| Home screen icon scales | `--set brandAssets.appIconSmall.scales=` | `brandAssets.appIconSmall.scales` | via `config` |
| App Store icon (all of the above) | `--set brandAssets.appIconLarge.*=` | `brandAssets.appIconLarge.*` | via `config` |
| Mark size on iOS | `--ios-icon-scale` | `iosIcon.iconScale` | `iosIconScale` |
| Mark size on tvOS | `--tv-icon-scale` | `brandAssets.iconScale` | `tvIconScale` |
| Per-layer parallax art | `--layer-front`, `--layer-middle`, `--layer-back` | `brandAssets.*.layers.*.imagePath` | `layers` |
| Per-layer source | `--set brandAssets.*.layers.*.source=` | `brandAssets.*.layers.*.source` | via `config` |
| Top Shelf on/off | `--no-top-shelf` | `brandAssets.topShelfImage(Wide).enabled` | via `config` |
Expand Down Expand Up @@ -187,7 +191,6 @@ Regenerate all assets automatically on every `expo prebuild`, for both tvOS (`EX
```json
"plugins": [
["tvos-assets/plugin", {
"icon": "./assets/brand/icon.svg",
"background": "./assets/brand/background.png",
"color": "#1C1C1E",
"iconBorderRadius": 0,
Expand All @@ -196,7 +199,7 @@ Regenerate all assets automatically on every `expo prebuild`, for both tvOS (`EX
]
```

Props are the **Plugin** column of the [options table](#options); all paths resolve relative to the project root. Anything without a dedicated prop is reachable through `config`, a path to a full JSON config file deep-merged under the props above.
No `icon` prop above: with art for both icon layers, the plugin assembles one. Props are the **Plugin** column of the [options table](#options); all paths resolve relative to the project root. Anything without a dedicated prop is reachable through `config`, a path to a full JSON config file deep-merged under the props above.

Install as a devDependency (`npm i -D tvos-assets`) and list the plugin **after** `expo-splash-screen` (and any TV config plugin, such as `@react-native-tvos/config-tv`) so the generated splash imagesets overwrite their single-icon output.

Expand Down Expand Up @@ -235,6 +238,57 @@ Registration matters. Icon-sourced layers are all placed identically (centered,

Open the generated `preview.html` and point at the imagestack to check your layer separation before building.

## Sizing the mark

Scale alone does not place a mark, because it sizes the *artboard* and your own
transparent margin then shrinks the mark inside it. Tomo TV's art fills 67.6% of
its 1024 artboard, so an 0.8 scale drew it at 54% of the icon.

So the icon is first normalised to the square its visible artwork occupies, and
the scale is applied to that. `--ios-icon-scale 0.8` means the mark covers 80% of
the icon, whatever padding your file carries:

| | Default | Why |
| --- | --- | --- |
| `--ios-icon-scale` | `0.8` | Apple's icon grid centres the primary shape at roughly 80% of the canvas, leaving about a 10% margin. |
| `--tv-icon-scale` | `0.75` | tvOS wants 10-15% of safe margin on every parallax layer: a focused icon scales up ~1.05-1.1x and its layers slide against each other, so content near an edge clips. 0.75 leaves 12.5% a side. |

One box is measured per run, from the flat icon, and applied to every surface
*and* every parallax layer. Sharing one transform is what keeps the layers
registered: normalising each layer to its own bounds would slide the front layer
off the middle one.

The splash screen logo is not affected. It already fills its own square.

## Assembling the icon from layer art

Because every icon-sourced layer sits on that same square artboard, the flat icon
is just those layers stacked. Supplying it separately means keeping a third file
in sync with two you already ship, so when you give art for **every** icon layer,
`--icon` becomes optional:

```bash
tvos-assets --background bg.png --color "#1C1C1E" \
--layer-front ./layer-front.svg --layer-middle ./layer-middle.svg
```

The icon is composited back to front from that art (the Back layer is the
background, so it is left out) and used everywhere a flat icon is needed: the iOS
`AppIcon.appiconset`, both Top Shelf images, the splash screen logo, and
`icon.png`. The imagestacks keep reading your layer files directly, so vector art
stays vector there.

Details worth knowing:

- **Every** icon layer needs art. With only `--layer-front`, the Middle layer
would still fall back to the icon being derived, so `--icon` stays required.
- When the two imagestacks carry different art, the icon is assembled from
`appIconLarge`.
- `iconBorderRadius` applies to the assembled icon, in the units of your layer
artboard, exactly as it would to a supplied one.
- `preview.html` lists the assembled icon after the layers it came from, so you
can check the composite against its parts.

## Programmatic API

```js
Expand Down
7 changes: 7 additions & 0 deletions plugin/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
* Usage in app.json (list AFTER expo-splash-screen and any TV config plugin so
* the generated assets overwrite their single-icon output):
*
* `icon` is optional when `layers` supplies art for both front and middle: the
* icon is then assembled from that art.
*
* ["tvos-assets/plugin", {
* "icon": "./assets/brand/icon.svg",
* "background": "./assets/brand/background.png",
* "color": "#1C1C1E",
* "darkColor": "#1C1C1E",
* "iconBorderRadius": 0,
* "iosIconScale": 0.8,
* "tvIconScale": 0.75,
* "iconDark": "./assets/brand/icon-dark.svg",
* "iconTinted": "./assets/brand/icon-tinted.svg",
* "layers": { "front": "./assets/brand/layer-front.svg", "middle": "./assets/brand/layer-middle.svg" },
Expand Down Expand Up @@ -80,6 +85,8 @@ function buildResolveArgs(projectRoot, props) {
iconTinted: resolveInput(projectRoot, props.iconTinted),
config: resolveInput(projectRoot, props.config),
iconBorderRadius: props.iconBorderRadius != null ? String(props.iconBorderRadius) : undefined,
iosIconScale: props.iosIconScale != null ? String(props.iosIconScale) : undefined,
tvIconScale: props.tvIconScale != null ? String(props.tvIconScale) : undefined,
// Not used for output (assets go straight into the xcassets catalog), but
// keeps resolveConfig's output-dir writability check pointed somewhere real.
outDir: path.join(projectRoot, "ios"),
Expand Down
90 changes: 75 additions & 15 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"properties": {
"iconImage": {
"type": "string",
"description": "Path to the app icon PNG or SVG. Must have a transparent background. The icon is composited onto the background image for app icons and Top Shelf images, and rendered standalone for the splash screen logo. SVGs are rasterized at the density each output size requires."
"description": "Path to the app icon PNG or SVG. Must have a transparent background. The icon is composited onto the background image for app icons and Top Shelf images, and rendered standalone for the splash screen logo. SVGs are rasterized at the density each output size requires. Optional when every icon layer of an imagestack carries its own art (brandAssets.<stack>.layers.front/middle.imagePath): the icon is then assembled from those layers, back to front."
},
"backgroundImage": {
"type": "string",
Expand Down Expand Up @@ -46,7 +46,10 @@
"description": "Optional PNG or SVG for the iOS tinted-appearance icon. When omitted, a grayscale version of the main icon is generated."
}
},
"required": ["iconImage", "backgroundImage", "backgroundColor"],
"required": [
"backgroundImage",
"backgroundColor"
],
"additionalProperties": false
},
"output": {
Expand All @@ -60,13 +63,16 @@
},
"mode": {
"type": "string",
"enum": ["zip", "dir"],
"enum": [
"zip",
"dir"
],
"default": "zip",
"description": "\"zip\" writes a timestamped archive. \"dir\" writes Images.xcassets directly into the output directory (what the Expo config plugin uses)."
},
"cleanBeforeGenerate": {
"type": "boolean",
"description": "Deprecated — ignored. Output is always generated in a fresh temp directory before zipping.",
"description": "Deprecated \u2014 ignored. Output is always generated in a fresh temp directory before zipping.",
"default": true
}
},
Expand Down Expand Up @@ -96,6 +102,13 @@
"topShelfImageWide": {
"$ref": "#/$defs/imageSetAsset",
"description": "Wide variant of the Top Shelf banner. Same compositing as topShelfImage. Default: 2320x720 at 1x and 2x."
},
"iconScale": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1,
"default": 0.75,
"description": "How much of the shorter tvOS output side the mark covers on the imagestacks and Top Shelf images, measured after the icon is normalized to its own artwork. Apple asks for a 10-15% safe margin on every parallax layer, because a focused icon scales up and its layers shift against each other; 0.75 leaves 12.5% a side."
}
},
"additionalProperties": false
Expand All @@ -113,6 +126,13 @@
"type": "string",
"description": "Folder name for the .appiconset bundle. Must match the ASSETCATALOG_COMPILER_APPICON_NAME build setting for the iOS target.",
"default": "AppIcon"
},
"iconScale": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1,
"default": 0.8,
"description": "How much of the 1024 canvas the mark covers, measured after the icon is normalized to its own artwork so a designer's transparent margin does not shrink it. Apple's icon grid centers the primary shape at roughly 80% of the canvas, with about a 10% margin."
}
},
"additionalProperties": false
Expand Down Expand Up @@ -153,7 +173,11 @@
"scales": {
"$ref": "#/$defs/scaleFactorArray",
"description": "Scale factors for universal idiom.",
"default": ["1x", "2x", "3x"]
"default": [
"1x",
"2x",
"3x"
]
}
},
"additionalProperties": false
Expand All @@ -165,7 +189,10 @@
"scales": {
"$ref": "#/$defs/scaleFactorArray",
"description": "Scale factors for tv idiom.",
"default": ["1x", "2x"]
"default": [
"1x",
"2x"
]
}
},
"additionalProperties": false
Expand Down Expand Up @@ -224,12 +251,18 @@
"$defs": {
"scaleFactor": {
"type": "string",
"enum": ["1x", "2x", "3x"],
"enum": [
"1x",
"2x",
"3x"
],
"description": "Image scale factor. 1x = base resolution, 2x = retina, 3x = super retina."
},
"scaleFactorArray": {
"type": "array",
"items": { "$ref": "#/$defs/scaleFactor" },
"items": {
"$ref": "#/$defs/scaleFactor"
},
"minItems": 1,
"uniqueItems": true
},
Expand All @@ -248,12 +281,18 @@
"description": "Height in points."
}
},
"required": ["width", "height"],
"required": [
"width",
"height"
],
"additionalProperties": false
},
"imageSource": {
"type": "string",
"enum": ["icon", "background"],
"enum": [
"icon",
"background"
],
"description": "Which input image to use. \"icon\" composites the icon on the background. \"background\" uses the background image only."
},
"layerConfig": {
Expand All @@ -269,7 +308,9 @@
"description": "Optional PNG or SVG that replaces the default source file for this layer, enabling true per-layer parallax art. The source field still controls rendering: icon layers sit centered on transparency, background layers cover-fill opaque."
}
},
"required": ["source"],
"required": [
"source"
],
"additionalProperties": false
},
"layers": {
Expand All @@ -289,7 +330,11 @@
"description": "Background layer (farthest from viewer). Typically the background image. Always rendered opaque."
}
},
"required": ["front", "middle", "back"],
"required": [
"front",
"middle",
"back"
],
"additionalProperties": false
},
"imageStackAsset": {
Expand Down Expand Up @@ -318,7 +363,13 @@
"description": "Layer configuration for the parallax imagestack."
}
},
"required": ["enabled", "name", "size", "scales", "layers"],
"required": [
"enabled",
"name",
"size",
"scales",
"layers"
],
"additionalProperties": false
},
"imageSetAsset": {
Expand Down Expand Up @@ -347,7 +398,13 @@
"description": "Filename prefix for generated PNGs. Produces files like {filePrefix}@1x.png."
}
},
"required": ["enabled", "name", "size", "scales", "filePrefix"],
"required": [
"enabled",
"name",
"size",
"scales",
"filePrefix"
],
"additionalProperties": false
},
"colorEntry": {
Expand All @@ -365,7 +422,10 @@
"description": "Color for dark mode (#RRGGBB)."
}
},
"required": ["light", "dark"],
"required": [
"light",
"dark"
],
"additionalProperties": false
}
}
Expand Down
Loading
Loading