Claude Skill

solo-apple-app-icon

Build an iOS 26+ layered app icon (.icon / Icon Composer) from a designer's SVG, generated by script rather than drawn by hand, and check every appearance without launching the app. Use when the user says "иконка приложения", "сделай иконку", "app icon", "Icon Composer", "layered

LLM Mart · 0 points · 9 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download fortunto2-solo-factory-skills_apple-app-icon-05ec009.zip · 11 KB
Part of fortunto2/solo-factory — 43 skills

Install

skills CLI npx skills add https://github.com/fortunto2/solo-factory/tree/main/skills/apple-app-icon
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install fortunto2-solo-factory@llmmart
Git git clone https://github.com/fortunto2/solo-factory.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole fortunto2/solo-factory collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

apple-app-icon — the icon as a build artefact, not a drawing

On iOS 26 an app icon is no longer a flattened PNG: it is a small bundle of layers the system lights, tints and renders as Liquid Glass. Icon Composer draws them by hand; this skill generates the same document from the brand artwork, so the icon is rebuilt when the brand changes rather than redrawn from memory.

Reach for it when the icon and the app disagree — different mark, different gradient, or nine PNGs whose source nobody can find.

Workflow

1. Find the real artwork before drawing anything

Two attempts went the wrong way on one project before the file turned up: rebuilding the mark from the app's own button geometry (beautiful, and a different mark), then tracing it off a 1024 PNG by measuring pixels (the right mark, at second-hand accuracy).

find ~ -maxdepth 7 \( -iname "*logo*" -o -iname "*icon*" \) \
     \( -iname "*.svg" -o -iname "*.ai" -o -iname "*.sketch" \) 2>/dev/null
mdfind -name "logo"          # useless if Spotlight is off — check `mdutil -s /`

Look in abandoned web projects and old zips; a Figma export usually survives somewhere even when the designer's archive link is dead. What you want is one artboard, already composed: the mark on its tile, with the background gradient in a <linearGradient>. That file is the source of truth — copy it into the repo (Resources/logo-source.svg) so the icon has a provenance.

2. Generate

python3 "${SKILL_DIR}/scripts/make_icon.py" \
    --source Resources/logo-source.svg \
    --icon Resources/App.icon --render

It reads the stops, the viewBox and the paths out of the artwork, splits the paths into layers, writes icon.json, and exports all six appearances through ictool. Nothing is retyped — a hex copied into a script is a second source of truth that drifts on the next export.

3. Look at all six, not at one

Default · Dark · TintedLight · TintedDark · ClearLight · ClearDark

The dark and tinted appearances are where a mark falls apart: the system re-colours the layers, and thin white lines with dark gaps — the shape of most marks — are exactly what a translucent treatment fills in. Rendering is three seconds; guessing costs a release.

4. Then see it among other icons

xcrun simctl install <UDID> path/to/App.app
xcrun simctl io <UDID> screenshot home.png     # after `axe button home`

A tile that looks confident at 512 points can be mush at 60 next to Photos and Safari. That is the only test that matters.

Wiring it into the project

# project.yml (XcodeGen)
sources:
  - path: Resources/App.icon
settings:
  base:
    ASSETCATALOG_COMPILER_APPICON_NAME: App     # the .icon's name, no extension

Keep the old AppIcon.appiconset beside it while the deployment target is below 26 — older systems fall back to it.

Gotchas

Each of these cost a render, and none is documented — the full set, with the measurements, is in references/icon-json.md.

  • Icon Composer fills contours and ignores strokes. <circle fill="none" stroke="…"> arrives as a filled disc: the first build of one icon came out as a white blob. Hand it outlines — a ring is an annulus with evenodd, an arc is a band with rounded ends.
  • Groups are drawn front to back. The background written first covers everything; it goes last.
  • fill belongs to the layer. On a group it is accepted and silently ignored — ictool returns 0 either way.
  • fill-specializations does nothing from JSON (Icon Composer 1.6). On a group it parses and changes nothing; on a layer the document stops loading. Per-appearance colours are a thing the GUI writes — if dark or monochrome must differ, open the .icon and set them there, then stop regenerating it.
  • A colour is display-p3:r,g,b,a, all four components. Three fail with "Expected four comma separated color components"; a bare hex with "missing ':' delimiter", which names the delimiter and not the prefix.
  • linear-gradient takes exactly two colours. A three-stop brand gradient has to be drawn as a background layer — at the cost that the system can no longer dim it for dark. Two colours it can dim; three it cannot touch. Pick which matters more and say so in a comment.
  • The artboard is already composed. Scaling it to "72% of the canvas, so it breathes" shrinks the mark twice — the designer's margin plus yours. Scale 1:1; use ~0.9 only if watchOS is claimed, because a circular mask eats the corners of a square's margin.
  • Do not claim platforms you do not ship. supported-platforms with circles: [watchOS] puts a round preview in front of you that no user will ever see, and tempts you into re-composing the icon for it.

Don't

  • Don't redraw the mark from the app's own code. The button inside the app and the icon on the home screen are two different objects; making the icon "match the code" produces a mark nobody recognises.
  • Don't trace a PNG when a vector exists. Measuring pixels gets the radii right and the end caps wrong.
  • Don't ship without looking at Dark. It is the appearance most likely to be ugly and the one nobody checks.
  • Don't leave the probes in the repo. Scripts written to interrogate the format belong in the commit message or in references/, not in scripts/.
Files (solo-factory)
  • references
    • icon-json.md 4.3 KB
      # `icon.json`, as measured
      
      Loaded on demand: read this when the generator needs changing, or when
      something in the document is accepted and does not work.
      
      The layered icon format is not published anywhere reachable, and its errors
      name the symptom rather than the syntax ("Invalid color encoding, missing ':'
      delimiter" — which names the delimiter and not the prefix). Everything below
      was got by asking `ictool` directly: write a document, render it, read the
      pixel back.
      
      ```bash
      ICTOOL="/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
      "$ICTOOL" App.icon --export-image --output-file out.png \
          --platform iOS --rendition Dark --width 128 --height 128 --scale 1
      ```
      
      `--rendition` takes: `Default`, `Dark`, `TintedLight`, `TintedDark`,
      `ClearLight`, `ClearDark`. (`Clear` alone is rejected, and the error lists the
      valid set — the one helpful message in the tool.)
      
      ## Shape of the document
      
      ```json
      {
        "fill": { "linear-gradient": ["display-p3:0,0.56,1,1", "display-p3:1,0.6,0.07,1"] },
        "groups": [
          { "layers": [ { "image-name": "play.svg", "name": "Play",
                          "fill": { "solid": "display-p3:1,1,1,1" } } ],
            "shadow": { "kind": "neutral", "opacity": 0.4 },
            "specular": true,
            "translucency": { "enabled": false, "value": 0.0 } },
          { "layers": [ { "image-name": "background.svg", "name": "Background" } ],
            "shadow": { "kind": "neutral", "opacity": 0.0 },
            "specular": false,
            "translucency": { "enabled": false, "value": 0.0 } }
        ],
        "supported-platforms": { "squares": ["iOS", "macOS"], "circles": ["watchOS"] }
      }
      ```
      
      The bundle is a directory: `App.icon/icon.json` plus `App.icon/Assets/*.svg`.
      
      ## What holds, and how it was found
      
      | Rule | How it showed up |
      |---|---|
      | A colour is `display-p3:r,g,b,a` | three components → "Expected four comma separated color components"; no prefix → "missing ':' delimiter" |
      | `fill` accepts `{"solid": …}`, `{"linear-gradient": [a, b]}`, `{"automatic-gradient": …}` | `{"linear-gradient": {"colors": […], "angle": …}}` → "the data couldn't be read" |
      | `linear-gradient` is **exactly two** colours | "Linear gradients require exactly 2 colors" |
      | `fill` works on a **layer** | filled a white circle red: red on the layer, unchanged on the group |
      | `fill` on a **group** is ignored | same test, `ictool` still returned 0 |
      | `fill-specializations` on a group parses and does nothing | asked for red in `dark`, got the system's own colouring |
      | `fill-specializations` on a layer breaks the document | "the data couldn't be read because it isn't in the correct format" |
      | Groups draw **front to back** | background written first covered the mark; the icon rendered as a bare gradient square |
      | SVG contours are **filled**, strokes ignored | `fill="none" stroke="#FFF"` rendered as a filled disc |
      
      ## What the system does that you cannot override
      
      In **Dark**, the layers are re-coloured from what is behind them: a white mark
      over a gradient background comes back rainbow — cyan at the top, amber at the
      bottom. A white `fill` does not stop it, and neither does `specular: false`
      (measured: the arc stayed at `(143, 95, 191)`).
      
      Removing the background *layer* changes it — with only the document's two-colour
      `fill` behind it, the same mark came back at `(41, 41, 41)`, i.e. the system dims
      the whole tile properly. So the trade is:
      
      | background as | Default | Dark / tinted |
      |---|---|---|
      | a **layer** (any number of stops) | exact | system cannot dim it; mark goes rainbow |
      | the document's **`fill`** (two colours) | two stops only | dims correctly, mark reads clean |
      
      A three-stop brand gradient and a correct dark appearance cannot both come out
      of JSON. Choose, comment the choice, or set the dark fill by hand in Icon
      Composer's own UI and stop regenerating that file.
      
      ## Reading the artwork instead of retyping it
      
      ```python
      re.search(r"<linearGradient[^>]*>(.*?)</linearGradient>", svg, re.S)   # the stops
      re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg)                     # the canvas
      re.findall(r'<path d="([^"]+)"', svg)                                  # the shapes
      ```
      
      Read the artboard's own numbers. The brand mark on one project is 431×429 —
      not square and not the 512 the earlier file happened to be — and hard-coding
      either puts the mark a few points off centre with nothing on screen to say why.
      
  • scripts
    • make_icon.py 14 KB
      #!/usr/bin/env python3
      """Build a layered `.icon` (Icon Composer) from a designer's SVG artboard.
      
      An app icon on iOS 26+ is no longer a flattened PNG: it is a small bundle of
      layers the system lights, tints and renders as Liquid Glass. Icon Composer
      draws them by hand; this writes the same document from the artwork, so the icon
      is regenerated rather than redrawn when the brand changes.
      
          make_icon.py --source brand/logo/mark-on-gradient.svg --icon App.icon --render
      
      What the source has to be: **one artboard, already composed** — the mark laid
      out on its tile, as outlines (not strokes), with the background gradient in a
      `<linearGradient>`. Everything else is read from it: the stops, the viewBox,
      the paths.
      
      Rendering needs `ictool`, which ships inside Xcode:
      `Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool`
      
      The format's rules — all measured against `ictool`, none documented — are in
      `references/icon-json.md`. Read that before changing anything here.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import pathlib
      import re
      import shutil
      import subprocess
      import sys
      
      ICTOOL = pathlib.Path(
          "/Applications/Xcode.app/Contents/Applications/Icon Composer.app"
          "/Contents/Executables/ictool"
      )
      
      CANVAS = 1024.0  # what an icon is drawn on
      
      # The artwork is a **finished tile**, 1080 square: the designer already decided
      # how much of it the mark takes and how much air is around it. Shrinking it to
      # 72% was the obvious-looking mistake — that number is for a mark handed over on
      # its own, and applying it to a laid-out tile shrinks the mark twice.
      #
      # 0.9 rather than 1.0 for one reason: **watchOS masks the tile to a circle**,
      # and a circle eats the corners of a square's margin. At 1:1 the outer arcs ran
      # to the very edge of the round preview. Nine tenths keeps the square generous
      # and gives the circle the room it needs — the same proportion the brand deck
      # itself uses when it puts the mark inside a round form
      # (`brand/presentation/slide2-4.pdf`).
      MARK_SPAN = 0.9
      
      # Set from the command line in `main`; the drawing functions read them.
      SOURCE = pathlib.Path()
      ICON = pathlib.Path()
      
      # Which of the source's five paths is which: the ring, the three arcs around
      # it, the play triangle. `brand/mark/mark-rings.svg` is the mark alone — no
      # background rectangle, because the layered format draws the background itself.
      PATHS = {"ring": [0], "arcs": [1, 2, 3], "play": [4]}
      
      # The source gradient, read from its own `<linearGradient>`: blue at the top
      # right through pink and orange to yellow at the bottom left.
      #
      # **A layered icon's background takes exactly two colours** — ictool refuses
      # more ("Linear gradients require exactly 2 colors"). Blue to yellow is a
      # straight line through grey, so the pair has to be chosen rather than taken
      # from the ends.
      # Nothing here is chosen. The background gradient is read out of the source
      # file's own `<linearGradient>` — see `source_gradient()`. Three stops, and the
      # layered format takes two, so it is drawn as the **bottom layer** rather than
      # as the document's `fill`: the middle stop is the whole point, because blue
      # interpolated straight to orange passes through grey and pink is what the
      # brand is remembered by. The two-colour `fill` beneath it is only what the
      # system falls back to when it re-colours the icon (tinted, clear).
      
      
      def colour(hex_string: str, alpha: float = 1.0) -> str:
          """`icon.json` wants `display-p3:r,g,b,a`, all four components.
      
          Undocumented; `ictool` was asked directly (`scripts/probe_icon_colour.py`).
          Three components fail with "Expected four comma separated color
          components", a bare hex with "missing ':' delimiter" — which names the
          delimiter and not the prefix.
          """
          h = hex_string.lstrip("#")
          r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4))
          return f"display-p3:{r:.4f},{g:.4f},{b:.4f},{alpha:.1f}"
      
      
      def source_paths() -> list[str]:
          """The `d` attribute of every path in the artwork, in file order."""
          return re.findall(r'<path d="([^"]+)"', SOURCE.read_text())
      
      
      def source_box() -> tuple[float, float]:
          """The artwork's own viewBox, read rather than assumed.
      
          The brand mark is 431×429 — not square, and not the 512 the first source
          happened to be. Hard-coding either is how a mark ends up a few points off
          centre with nothing on screen to say why.
          """
          box = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', SOURCE.read_text())
          return (float(box.group(1)), float(box.group(2))) if box else (CANVAS, CANVAS)
      
      
      def layer_svg(indices: list[int], paths: list[str]) -> str:
          """One layer: the named paths, scaled and centred on the icon canvas.
      
          Filled white and nothing else. Icon Composer rasterises an SVG by filling
          its contours — a stroked path arrives as a filled disc, which is how the
          first build of this came out as a white blob — and the source is already
          outlines, so nothing has to be converted.
          """
          width, height = source_box()
          scale = CANVAS * MARK_SPAN / max(width, height)
          dx = (CANVAS - width * scale) / 2
          dy = (CANVAS - height * scale) / 2
          body = "".join(f'<path d="{paths[i]}"/>' for i in indices)
          return (
              f'<svg xmlns="http://www.w3.org/2000/svg" width="{CANVAS:g}" '
              f'height="{CANVAS:g}" viewBox="0 0 {CANVAS:g} {CANVAS:g}">'
              f'<g transform="translate({dx:.2f} {dy:.2f}) scale({scale:.4f})" '
              f'fill="#FFFFFF" fill-rule="evenodd">{body}</g></svg>\n'
          )
      
      
      def source_gradient() -> list[tuple[float, str]]:
          """The background gradient's stops, read out of the source file.
      
          The source is the designer's own artboard — white mark on the brand
          gradient — so the colours are in it and there is nothing to choose.
          Reading them beats copying them: a hex retyped into this file is a second
          source of truth that drifts the first time the artwork is re-exported.
          """
          text = SOURCE.read_text()
          block = re.search(r"<linearGradient[^>]*>(.*?)</linearGradient>", text, re.S)
          if not block:
              return []
          stops = []
          for stop in re.finditer(r"<stop([^>]*)/>", block.group(1)):
              attrs = stop.group(1)
              offset = re.search(r'offset="([\d.]+)"', attrs)
              colour_hex = re.search(r'stop-color="(#[0-9A-Fa-f]{6})"', attrs)
              if colour_hex:
                  stops.append(
                      (float(offset.group(1)) if offset else 0.0, colour_hex.group(1))
                  )
          return stops
      
      
      def background_svg() -> str:
          """The gradient as a full-bleed layer, with the artwork's own direction.
      
          `x1=1080 y1=0 → x2=0 y2=1080` in the source: top-right to bottom-left. The
          diagonal is what lets all three stops show on a square.
          """
          stops = "".join(
              f'<stop offset="{at:.4f}" stop-color="{hexcode}"/>'
              for at, hexcode in source_gradient()
          )
          return (
              f'<svg xmlns="http://www.w3.org/2000/svg" width="{CANVAS:g}" '
              f'height="{CANVAS:g}" viewBox="0 0 {CANVAS:g} {CANVAS:g}">'
              f'<defs><linearGradient id="bg" x1="1" y1="0" x2="0" y2="1">{stops}'
              f"</linearGradient></defs>"
              f'<rect width="{CANVAS:g}" height="{CANVAS:g}" fill="url(#bg)"/></svg>\n'
          )
      
      
      def document(flat_background: bool) -> dict:
          """`icon.json` — the layer stack, and how much glass is applied to it.
      
          **Translucency is off.** With it on (0.4–0.5, which is what the format
          suggests) the three arcs, the ring and the triangle all became milky and
          ran together: at 60 points the mark read as one soft blob rather than as
          two rings around a play button. The whole point of this mark is thin white
          lines with dark gaps between them, and translucency fills exactly those
          gaps. Specular and the shadow stay — they are what make it sit in Liquid
          Glass — but the ink stays opaque.
      
          Three groups rather than one, so each is lit separately and the highlight
          travels across the mark as the phone tilts.
          """
          stops = source_gradient()
          ends = (stops[0][1], stops[-1][1]) if len(stops) >= 2 else ("#008FFF", "#FF9A12")
          white = colour("#FFFFFF")
      
          def group(
              image: str, name: str, shadow: float, lit: bool = True, fill: dict | None = None
          ) -> dict:
              # **`fill` belongs to the layer, not the group** — measured by filling a
              # white circle red: on the layer it came back red, on the group white,
              # and `ictool` returned 0 both times. An accepted key that does nothing
              # is worse than a rejected one.
              #
              # **`fill-specializations` does nothing at all here.** On a group it is
              # accepted and ignored (dark stayed the system's own colouring, not the
              # red asked for); on a layer the document stops parsing entirely — "the
              # data couldn't be read". Per-appearance colours are a thing the app's
              # own UI writes and this file format, at Icon Composer 1.6, does not
              # take from JSON. So: one fill, and it holds everywhere.
              layer: dict = {"image-name": image, "name": name}
              if fill:
                  layer["fill"] = fill
              return {
                  "layers": [layer],
                  "shadow": {"kind": "neutral", "opacity": shadow},
                  "specular": lit,
                  "translucency": {"enabled": False, "value": 0.0},
              }
      
          # White on the gradient, which is most brands' main lock-up.
          #
          # In the **dark** appearance the system re-colours the mark from the
          # background gradient by itself, and a white fill declared for dark does not
          # stop it — measured, twice. That turned out to be the right answer rather
          # than a fight: a brand set that carries a white-on-gradient lock-up almost
          # always carries the coloured-mark-on-black one too. So the fix is not to
          # keep the mark white but to take the gradient off the background, which is
          # what the specialisation below does.
          ink = {"solid": white}
      
          groups = [
              group("play.svg", "Play", 0.4, fill=ink),
              group("ring.svg", "Ring", 0.35, fill=ink),
              group("arcs.svg", "Arcs", 0.3, fill=ink),
          ]
          if not flat_background:
              groups.append(group("background.svg", "Background", 0.0, lit=False))
      
          return {
              # Two ends only — the format takes no more. With `flat_background` this
              # *is* the background; otherwise it is what the tinted and clear
              # appearances fall back to.
              "fill": {"linear-gradient": [colour(ends[0]), colour(ends[1])]},
              # **Last is furthest back.** The list is drawn front to back — the
              # background written first covered the whole mark, and the icon came
              # out as a bare gradient square.
              "groups": groups,
              "supported-platforms": {"circles": ["watchOS"], "squares": ["iOS", "macOS"]},
          }
      
      
      def build(flat_background: bool) -> None:
          paths = source_paths()
          if len(paths) < 5:
              print(f"{SOURCE.name} has {len(paths)} paths, expected 5", file=sys.stderr)
              raise SystemExit(1)
          assets = ICON / "Assets"
          if ICON.exists():
              shutil.rmtree(ICON)
          assets.mkdir(parents=True)
          if not flat_background:
              (assets / "background.svg").write_text(background_svg())
          for name, indices in PATHS.items():
              (assets / f"{name}.svg").write_text(layer_svg(indices, paths))
          (ICON / "icon.json").write_text(
              json.dumps(document(flat_background), indent=2) + "\n"
          )
          stops = " → ".join(hexcode for _, hexcode in source_gradient())
          print(
              f"wrote {ICON} — background {stops}, "
              f"{len(PATHS)} mark layers from {SOURCE.name}, mark {MARK_SPAN:.0%} of the canvas"
          )
      
      
      # Every appearance the system can put the icon in. Rendering all of them is the
      # only way to see what tinting does to a mark made of separate white shapes.
      RENDITIONS = ["Default", "Dark", "TintedLight", "TintedDark", "ClearLight", "ClearDark"]
      
      
      def render(out_dir: pathlib.Path) -> int:
          if not ICTOOL.exists():
              print(f"ictool not found at {ICTOOL}", file=sys.stderr)
              return 1
          out_dir.mkdir(parents=True, exist_ok=True)
          failed = 0
          for rendition in RENDITIONS:
              out = out_dir / f"icon-{rendition.lower()}.png"
              result = subprocess.run(
                  [
                      str(ICTOOL),
                      str(ICON),
                      "--export-image",
                      "--output-file",
                      str(out),
                      "--platform",
                      "iOS",
                      "--rendition",
                      rendition,
                      "--width",
                      "512",
                      "--height",
                      "512",
                      "--scale",
                      "1",
                  ],
                  capture_output=True,
                  text=True,
              )
              if result.returncode != 0 or not out.exists():
                  failed += 1
                  print(f"  ✗ {rendition}: {result.stderr.strip() or result.stdout.strip()}")
              else:
                  print(f"  ✓ {rendition}: {out}")
          return failed
      
      
      def main() -> int:
          global SOURCE, ICON, MARK_SPAN
          ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
          ap.add_argument(
              "--source",
              required=True,
              help="the designer's SVG artboard: mark on its tile, outlines",
          )
          ap.add_argument("--icon", required=True, help="the .icon bundle to write")
          ap.add_argument(
              "--mark-span",
              type=float,
              default=MARK_SPAN,
              help="how much of the canvas the artboard takes (0.9 leaves "
              "the room watchOS's circular mask needs)",
          )
          ap.add_argument(
              "--flat-background",
              action="store_true",
              help="drop the three-stop layer; let the two-colour fill be the "
              "background, which is the only form dark and tinted can dim",
          )
          ap.add_argument("--render", action="store_true")
          ap.add_argument("--out", default=None)
          args = ap.parse_args()
          SOURCE = pathlib.Path(args.source)
          ICON = pathlib.Path(args.icon)
          MARK_SPAN = args.mark_span
          if not SOURCE.exists():
              print(f"no artwork at {SOURCE}", file=sys.stderr)
              return 1
          build(args.flat_background)
          if not args.render:
              return 0
          return render(pathlib.Path(args.out or ICON.parent / "icon-previews"))
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 6 KB
    ---
    name: solo-apple-app-icon
    description: Build an iOS 26+ layered app icon (.icon / Icon Composer) from a designer's SVG, generated by script rather than drawn by hand, and check every appearance without launching the app. Use when the user says "иконка приложения", "сделай иконку", "app icon", "Icon Composer", "layered icon", "иконка не как в фигме", "тёмная иконка выглядит плохо", or when an app still ships a folder of flattened PNGs. Do NOT use for in-app icons and SF Symbols (that is symbol work), or for the App Store screenshot set.
    license: MIT
    metadata:
      author: fortunto2
      version: "1.0.0"
      openclaw:
        emoji: "🟪"
    ---
    
    # apple-app-icon — the icon as a build artefact, not a drawing
    
    On iOS 26 an app icon is no longer a flattened PNG: it is a small bundle of
    **layers** the system lights, tints and renders as Liquid Glass. Icon Composer
    draws them by hand; this skill generates the same document from the brand
    artwork, so the icon is *rebuilt* when the brand changes rather than redrawn
    from memory.
    
    Reach for it when the icon and the app disagree — different mark, different
    gradient, or nine PNGs whose source nobody can find.
    
    ## Workflow
    
    ### 1. Find the real artwork before drawing anything
    
    Two attempts went the wrong way on one project before the file turned up:
    rebuilding the mark from the app's own button geometry (beautiful, and a
    different mark), then tracing it off a 1024 PNG by measuring pixels (the right
    mark, at second-hand accuracy).
    
    ```bash
    find ~ -maxdepth 7 \( -iname "*logo*" -o -iname "*icon*" \) \
         \( -iname "*.svg" -o -iname "*.ai" -o -iname "*.sketch" \) 2>/dev/null
    mdfind -name "logo"          # useless if Spotlight is off — check `mdutil -s /`
    ```
    
    Look in abandoned web projects and old zips; a Figma export usually survives
    somewhere even when the designer's archive link is dead. **What you want is one
    artboard, already composed**: the mark on its tile, with the background
    gradient in a `<linearGradient>`. That file is the source of truth — copy it
    into the repo (`Resources/logo-source.svg`) so the icon has a provenance.
    
    ### 2. Generate
    
    ```bash
    python3 "${SKILL_DIR}/scripts/make_icon.py" \
        --source Resources/logo-source.svg \
        --icon Resources/App.icon --render
    ```
    
    It reads the stops, the viewBox and the paths out of the artwork, splits the
    paths into layers, writes `icon.json`, and exports all six appearances through
    `ictool`. Nothing is retyped — a hex copied into a script is a second source of
    truth that drifts on the next export.
    
    ### 3. Look at all six, not at one
    
    ```
    Default · Dark · TintedLight · TintedDark · ClearLight · ClearDark
    ```
    
    The dark and tinted appearances are where a mark falls apart: the system
    re-colours the layers, and thin white lines with dark gaps — the shape of most
    marks — are exactly what a translucent treatment fills in. Rendering is three
    seconds; guessing costs a release.
    
    ### 4. Then see it among other icons
    
    ```bash
    xcrun simctl install <UDID> path/to/App.app
    xcrun simctl io <UDID> screenshot home.png     # after `axe button home`
    ```
    
    A tile that looks confident at 512 points can be mush at 60 next to Photos and
    Safari. That is the only test that matters.
    
    ## Wiring it into the project
    
    ```yaml
    # project.yml (XcodeGen)
    sources:
      - path: Resources/App.icon
    settings:
      base:
        ASSETCATALOG_COMPILER_APPICON_NAME: App     # the .icon's name, no extension
    ```
    
    Keep the old `AppIcon.appiconset` beside it while the deployment target is
    below 26 — older systems fall back to it.
    
    ## Gotchas
    
    Each of these cost a render, and none is documented — the full set, with the
    measurements, is in `references/icon-json.md`.
    
    - **Icon Composer fills contours and ignores strokes.** `<circle fill="none"
      stroke="…">` arrives as a filled disc: the first build of one icon came out
      as a white blob. Hand it outlines — a ring is an annulus with `evenodd`, an
      arc is a band with rounded ends.
    - **Groups are drawn front to back.** The background written first covers
      everything; it goes **last**.
    - **`fill` belongs to the layer.** On a group it is accepted and silently
      ignored — `ictool` returns 0 either way.
    - **`fill-specializations` does nothing from JSON** (Icon Composer 1.6). On a
      group it parses and changes nothing; on a layer the document stops loading.
      Per-appearance colours are a thing the GUI writes — if dark or monochrome
      must differ, open the `.icon` and set them there, then stop regenerating it.
    - **A colour is `display-p3:r,g,b,a`, all four components.** Three fail with
      "Expected four comma separated color components"; a bare hex with "missing
      ':' delimiter", which names the delimiter and not the prefix.
    - **`linear-gradient` takes exactly two colours.** A three-stop brand gradient
      has to be drawn as a background *layer* — at the cost that the system can no
      longer dim it for dark. Two colours it can dim; three it cannot touch. Pick
      which matters more and say so in a comment.
    - **The artboard is already composed.** Scaling it to "72% of the canvas, so it
      breathes" shrinks the mark twice — the designer's margin plus yours. Scale
      1:1; use ~0.9 only if watchOS is claimed, because a circular mask eats the
      corners of a square's margin.
    - **Do not claim platforms you do not ship.** `supported-platforms` with
      `circles: [watchOS]` puts a round preview in front of you that no user will
      ever see, and tempts you into re-composing the icon for it.
    
    ## Don't
    
    - **Don't redraw the mark from the app's own code.** The button inside the app
      and the icon on the home screen are two different objects; making the icon
      "match the code" produces a mark nobody recognises.
    - **Don't trace a PNG when a vector exists.** Measuring pixels gets the radii
      right and the end caps wrong.
    - **Don't ship without looking at Dark.** It is the appearance most likely to
      be ugly and the one nobody checks.
    - **Don't leave the probes in the repo.** Scripts written to interrogate the
      format belong in the commit message or in `references/`, not in `scripts/`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related