From 754bdbf4400832de0186961a2336f905996803e4 Mon Sep 17 00:00:00 2001 From: Quang Tran Date: Mon, 11 May 2026 12:19:47 +0700 Subject: [PATCH] feat: add ios-icon-gen skill (#1356) * feat: add ios-icon-gen skill for Xcode asset catalog icon generation Add a skill that generates PNG icon imagesets (1x, 2x, 3x) for Xcode asset catalogs from two sources: - Iconify API: 275k+ open source icons from 200+ collections (Material Design, Phosphor, Tabler, Lucide, etc.) - SF Symbols: 5k+ Apple-native symbols (macOS only) Includes search, preview, and generation scripts with customizable size, color, weight, and direct output to asset catalogs. * fix: address PR review feedback for ios-icon-gen skill Security: - Fix shell injection in iconify_gen.sh by passing query via sys.argv instead of interpolating into Python string literal Robustness: - Replace all try!/force-unwrap with do/try/catch and guard let in generate_icons.swift for graceful error handling - Add option value validation (require_value/requireOptionValue) in both scripts to prevent crashes on missing flag values - Add curl timeouts (--connect-timeout 10, --max-time 30) to all network calls - Add sips conversion failure warnings instead of silent suppression - Add error handling for curl in list_collections Documentation: - Rename SKILL.md sections to "When to Use", "How It Works", "Examples" to match repo conventions * fix: restore canonical SKILL.md headers and validate color/weight CLI inputs - Revert SKILL.md section headers back to "When to Activate" and "Core Principles" per CONTRIBUTING.md and SKILL-DEVELOPMENT-GUIDE.md (the prior rename to "When to Use"/"How It Works" was incorrect) - Validate --color as a 6-digit hex code at parse time instead of silently falling back to the default gray - Validate --weight against the known set of font weights instead of silently falling back to thin --------- Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com> --- skills/ios-icon-gen/SKILL.md | 157 +++++++++++ .../ios-icon-gen/scripts/generate_icons.swift | 258 ++++++++++++++++++ skills/ios-icon-gen/scripts/iconify_gen.sh | 235 ++++++++++++++++ 3 files changed, 650 insertions(+) create mode 100644 skills/ios-icon-gen/SKILL.md create mode 100755 skills/ios-icon-gen/scripts/generate_icons.swift create mode 100755 skills/ios-icon-gen/scripts/iconify_gen.sh diff --git a/skills/ios-icon-gen/SKILL.md b/skills/ios-icon-gen/SKILL.md new file mode 100644 index 00000000..a8762551 --- /dev/null +++ b/skills/ios-icon-gen/SKILL.md @@ -0,0 +1,157 @@ +--- +name: ios-icon-gen +description: Generate iOS app icons as PNG imagesets for Xcode asset catalogs from SF Symbols (5000+ Apple-native) or Iconify API (275k+ open source icons from 200+ collections). Use when generating icons, creating icon assets, adding icons to asset catalog, or searching for icons for iOS projects. +origin: community +--- + +# iOS Icon Generator + +Generate PNG icon imagesets for Xcode asset catalogs from two sources. + +## When to Activate + +- Generating icon assets for an iOS/macOS Xcode project +- Searching for icons across open source collections +- Creating PNG imagesets (1x, 2x, 3x) for asset catalogs +- Replacing placeholder icons with production-quality assets +- Matching existing icon styles in an Xcode project + +## Core Principles + +### 1. Two Sources, One Output Format +Both sources produce identical Xcode-compatible imagesets. Choose based on need: + +| Source | Icons | Requires | Best for | +|--------|-------|----------|----------| +| **Iconify API** | 275,000+ from 200+ collections | Internet | Wide selection, specific styles, open source icons | +| **SF Symbols** | 5,000+ Apple symbols | macOS only | Apple-native style, offline use | + +### 2. Always Match Existing Style +Before generating, check the project's existing icons for size, color, and weight consistency. + +### 3. Output Structure +Both methods produce a complete Xcode imageset: + +``` +/.imageset/ + Contents.json + .png # 1x (68px default) + @2x.png # 2x (136px default) + @3x.png # 3x (204px default) +``` + +## Examples + +### Step 1: Assess Requirements + +Determine icon needs: what the icon represents, preferred style, target color, and size. + +If the project already has icons, check existing style: +```bash +# Check dimensions of existing icon +sips -g pixelWidth -g pixelHeight path/to/existing@2x.png +``` + +### Step 2: Search for Icons + +**Iconify API (recommended for wide selection):** +```bash +# Search all collections +$SKILL_DIR/scripts/iconify_gen.sh search "receipt" + +# Search within a specific collection +$SKILL_DIR/scripts/iconify_gen.sh search "business card" --prefix mdi + +# List available collections +$SKILL_DIR/scripts/iconify_gen.sh collections +``` + +**SF Symbols (for Apple-native style):** +Browse the SF Symbols app or reference common names: + +| Use Case | Symbol Name | +|----------|-------------| +| Document | `doc.text`, `doc.fill` | +| Receipt | `doc.text.below.ecg`, `receipt` | +| Person | `person.crop.rectangle`, `person.text.rectangle` | +| Camera | `camera`, `camera.fill` | +| Scan | `doc.viewfinder`, `qrcode.viewfinder` | +| Settings | `gearshape`, `slider.horizontal.3` | + +### Step 3: Preview (Optional) + +```bash +# Iconify preview +$SKILL_DIR/scripts/iconify_gen.sh preview mdi:receipt-text-outline +``` + +### Step 4: Generate + +**Iconify API:** +```bash +# Basic generation +$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline editTool_expenseReport + +# Custom color and output location +$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline myIcon --color 007AFF --output ./Assets.xcassets/icons +``` + +Options: `--size ` (default: 68), `--color ` (default: 8E8E93), `--output ` (default: /tmp/icons) + +**SF Symbols:** +```bash +# Basic generation +swift $SKILL_DIR/scripts/generate_icons.swift doc.text.below.ecg editTool_expenseReport + +# Custom color, weight, and output +swift $SKILL_DIR/scripts/generate_icons.swift person.crop.rectangle myIcon --color 007AFF --weight regular --output ./Assets.xcassets/icons +``` + +Options: `--size ` (default: 68), `--color ` (default: 8E8E93), `--weight ` (default: thin), `--output ` (default: /tmp/icons) + +### Step 5: Verify and Integrate + +1. Read the generated @2x PNG to verify visually +2. Copy to asset catalog if not output there directly: + ```bash + cp -r /tmp/icons/.imageset path/to/Assets.xcassets// + ``` +3. Build the project to verify Xcode picks up the new assets + +## Popular Iconify Collections + +| Prefix | Name | Count | Style | +|--------|------|-------|-------| +| `mdi` | Material Design Icons | 7400+ | Filled + outline variants | +| `ph` | Phosphor | 9000+ | 6 weights per icon | +| `solar` | Solar | 7400+ | Bold, linear, outline | +| `tabler` | Tabler Icons | 6000+ | Consistent stroke width | +| `lucide` | Lucide | 1700+ | Clean, minimal | +| `ri` | Remix Icon | 3100+ | Filled + line variants | +| `carbon` | Carbon | 2400+ | IBM design language | +| `heroicons` | HeroIcons | 1200+ | Tailwind CSS companion | + +Browse all: https://icon-sets.iconify.design/ + +## Scripts Reference + +| Script | Source | Path | +|--------|--------|------| +| `iconify_gen.sh` | Iconify API (275k+ icons) | `$SKILL_DIR/scripts/iconify_gen.sh` | +| `generate_icons.swift` | SF Symbols (5k+ icons) | `$SKILL_DIR/scripts/generate_icons.swift` | + +## Best Practices + +- **Search before generating** -- browse available icons to find the best match +- **Match existing project style** -- check dimensions, color, and weight of existing icons before generating new ones +- **Use Iconify for variety** -- 200+ collections means you can find the exact style you need +- **Use SF Symbols for Apple consistency** -- they match system UI perfectly +- **Generate directly to asset catalog** -- use `--output ./Assets.xcassets/icons` to skip manual copying +- **Verify visually** -- always preview the @2x PNG before committing + +## Anti-Patterns + +- Generating icons without checking existing project icon style +- Using default colors when the project has a defined color palette +- Generating at wrong sizes (check existing icons first) +- Committing generated icons without visual verification diff --git a/skills/ios-icon-gen/scripts/generate_icons.swift b/skills/ios-icon-gen/scripts/generate_icons.swift new file mode 100755 index 00000000..98b244e9 --- /dev/null +++ b/skills/ios-icon-gen/scripts/generate_icons.swift @@ -0,0 +1,258 @@ +#!/usr/bin/env swift + +import AppKit +import Foundation + +// MARK: - Configuration + +struct IconSpec { + let symbolName: String + let assetName: String + let baseSize: CGFloat + let color: NSColor + let weight: NSFont.Weight +} + +func parseColor(_ hex: String) -> NSColor { + var hex = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if hex.hasPrefix("#") { hex.removeFirst() } + guard hex.count == 6, let value = UInt64(hex, radix: 16) else { + return NSColor(red: 142/255, green: 142/255, blue: 147/255, alpha: 1.0) + } + return NSColor( + red: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1.0 + ) +} + +func parseWeight(_ name: String) -> NSFont.Weight { + switch name.lowercased() { + case "ultralight": return .ultraLight + case "thin": return .thin + case "light": return .light + case "regular": return .regular + case "medium": return .medium + case "semibold": return .semibold + case "bold": return .bold + case "heavy": return .heavy + case "black": return .black + default: return .thin + } +} + +// MARK: - Generation + +enum IconError: Error, CustomStringConvertible { + case directoryCreation(String) + case symbolNotFound(String) + case configurationFailed(String) + case pngCreation(String) + case fileWrite(String) + + var description: String { + switch self { + case .directoryCreation(let msg): return msg + case .symbolNotFound(let msg): return msg + case .configurationFailed(let msg): return msg + case .pngCreation(let msg): return msg + case .fileWrite(let msg): return msg + } + } +} + +func generateIcon(_ spec: IconSpec, outputDir: String) throws { + let dir = "\(outputDir)/\(spec.assetName).imageset" + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } catch { + throw IconError.directoryCreation("Could not create output directory '\(dir)': \(error.localizedDescription)") + } + + let scales: [(suffix: String, multiplier: CGFloat)] = [("", 1), ("@2x", 2), ("@3x", 3)] + + for scale in scales { + let pixelSize = spec.baseSize * scale.multiplier + let imageSize = NSSize(width: pixelSize, height: pixelSize) + + let config = NSImage.SymbolConfiguration( + pointSize: pixelSize * 0.40, + weight: spec.weight, + scale: .large + ) + + guard let symbol = NSImage(systemSymbolName: spec.symbolName, accessibilityDescription: nil) else { + throw IconError.symbolNotFound("SF Symbol '\(spec.symbolName)' not found. Run 'SF Symbols' app to browse available names.") + } + + guard let configured = symbol.withSymbolConfiguration(config) else { + throw IconError.configurationFailed("Could not apply symbol configuration to '\(spec.symbolName)'") + } + + let image = NSImage(size: imageSize, flipped: false) { rect in + let symSize = configured.size + let x = (rect.width - symSize.width) / 2 + let y = (rect.height - symSize.height) / 2 + let drawRect = NSRect(x: x, y: y, width: symSize.width, height: symSize.height) + + let tinted = NSImage(size: symSize, flipped: false) { tintRect in + configured.draw(in: tintRect) + spec.color.set() + tintRect.fill(using: .sourceAtop) + return true + } + + tinted.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0) + return true + } + + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { + throw IconError.pngCreation("Failed to create PNG for \(spec.assetName)\(scale.suffix)") + } + + let fileName = "\(spec.assetName)\(scale.suffix).png" + do { + try pngData.write(to: URL(fileURLWithPath: "\(dir)/\(fileName)")) + } catch { + throw IconError.fileWrite("Failed to write \(fileName): \(error.localizedDescription)") + } + print(" \(fileName) (\(Int(pixelSize))x\(Int(pixelSize)))") + } + + // Write Contents.json + let json = """ + { + "images" : [ + { + "filename" : "\(spec.assetName).png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "\(spec.assetName)@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "\(spec.assetName)@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } + } + """ + do { + try json.write(toFile: "\(dir)/Contents.json", atomically: true, encoding: .utf8) + } catch { + throw IconError.fileWrite("Failed to write Contents.json: \(error.localizedDescription)") + } +} + +func requireOptionValue(_ args: [String], at index: Int, flag: String) -> String { + guard index < args.count else { + fputs("ERROR: Missing value for \(flag)\n", stderr) + exit(1) + } + let value = args[index] + if value.hasPrefix("--") { + fputs("ERROR: Missing value for \(flag)\n", stderr) + exit(1) + } + return value +} + +// MARK: - CLI + +let args = CommandLine.arguments + +if args.count < 3 || args.contains("--help") || args.contains("-h") { + print(""" + Usage: generate_icons.swift [options] + + Options: + --size Base size in points (default: 68) + --color Color hex code (default: 8E8E93) + --weight Font weight: ultralight|thin|light|regular|medium|semibold|bold|heavy|black (default: thin) + --output Output directory (default: /tmp/icons) + + Examples: + generate_icons.swift doc.text.below.ecg editTool_expenseReport + generate_icons.swift person.crop.rectangle editTool_businessCard --color 007AFF --weight regular + generate_icons.swift receipt myReceipt --size 48 --output ./Assets.xcassets/icons + + Browse SF Symbol names: open the SF Symbols app (free from Apple) or https://developer.apple.com/sf-symbols/ + """) + exit(0) +} + +let symbolName = args[1] +let assetName = args[2] + +var baseSize: CGFloat = 68 +var colorHex = "8E8E93" +var weightName = "thin" +var outputDir = "/tmp/icons" + +var i = 3 +while i < args.count { + switch args[i] { + case "--size": + let raw = requireOptionValue(args, at: i + 1, flag: "--size") + guard let size = Double(raw), size > 0 else { + fputs("ERROR: --size must be a positive number\n", stderr) + exit(1) + } + baseSize = CGFloat(size) + i += 2 + continue + case "--color": + colorHex = requireOptionValue(args, at: i + 1, flag: "--color") + let stripped = colorHex.hasPrefix("#") ? String(colorHex.dropFirst()) : colorHex + guard stripped.count == 6, UInt64(stripped, radix: 16) != nil else { + fputs("ERROR: --color must be a 6-digit hex code (e.g. 007AFF)\n", stderr) + exit(1) + } + i += 2 + continue + case "--weight": + weightName = requireOptionValue(args, at: i + 1, flag: "--weight") + let validWeights = ["ultralight", "thin", "light", "regular", "medium", "semibold", "bold", "heavy", "black"] + guard validWeights.contains(weightName.lowercased()) else { + fputs("ERROR: --weight must be one of: \(validWeights.joined(separator: ", "))\n", stderr) + exit(1) + } + i += 2 + continue + case "--output": + outputDir = requireOptionValue(args, at: i + 1, flag: "--output") + i += 2 + continue + default: + fputs("WARNING: Unknown option \(args[i])\n", stderr) + } + i += 1 +} + +let spec = IconSpec( + symbolName: symbolName, + assetName: assetName, + baseSize: baseSize, + color: parseColor(colorHex), + weight: parseWeight(weightName) +) + +print("Generating \(assetName) from SF Symbol '\(symbolName)':") +do { + try generateIcon(spec, outputDir: outputDir) + print("Output: \(outputDir)/\(assetName).imageset/") +} catch { + fputs("ERROR: \(error)\n", stderr) + exit(1) +} diff --git a/skills/ios-icon-gen/scripts/iconify_gen.sh b/skills/ios-icon-gen/scripts/iconify_gen.sh new file mode 100755 index 00000000..233d6cef --- /dev/null +++ b/skills/ios-icon-gen/scripts/iconify_gen.sh @@ -0,0 +1,235 @@ +#!/bin/bash +# +# Generate iOS icon imagesets from Iconify API (275k+ open source icons) +# Uses: curl (download SVG) + sips (SVG->PNG conversion, built into macOS) +# +# Usage: +# iconify_gen.sh [options] +# iconify_gen.sh search [--prefix ] [--limit ] +# +# Examples: +# iconify_gen.sh mdi:receipt-text-outline myExpenseIcon +# iconify_gen.sh search "business card" +# iconify_gen.sh search receipt --prefix mdi + +set -euo pipefail + +API_BASE="https://api.iconify.design" +readonly CURL_OPTS=(--fail --silent --show-error --connect-timeout 10 --max-time 30) + +# Defaults +SIZE=68 +COLOR="8E8E93" +OUTPUT="/tmp/icons" +LIMIT=20 + +require_value() { + local flag="$1" + local value="${2-}" + if [[ -z "$value" || "$value" == --* ]]; then + echo "ERROR: ${flag} requires a value" >&2 + exit 1 + fi +} + +usage() { + cat <<'EOF' +Usage: + iconify_gen.sh [options] Generate an icon imageset + iconify_gen.sh search [options] Search for icons + iconify_gen.sh preview Download preview SVG + iconify_gen.sh collections List popular icon collections + +Generate Options: + --size Base size in points (default: 68) + --color Color hex without # (default: 8E8E93) + --output Output directory (default: /tmp/icons) + +Search Options: + --prefix Filter by collection (e.g., mdi, lucide, tabler, ph) + --limit Max results (default: 20) + +Icon ID Format: : + Examples: mdi:receipt-text-outline, lucide:credit-card, ph:address-book + +Popular Collections: + mdi Material Design Icons (7400+ icons) + lucide Lucide (1700+ icons) + tabler Tabler Icons (6000+ icons) + ph Phosphor (9000+ icons) + ri Remix Icon (2800+ icons) + carbon Carbon (2100+ icons) +EOF + exit 0 +} + +search_icons() { + local query="$1" + shift + local prefix="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) require_value --prefix "${2-}"; prefix="$2"; shift 2 ;; + --limit) require_value --limit "${2-}"; LIMIT="$2"; shift 2 ;; + *) shift ;; + esac + done + + local encoded_query + encoded_query="$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$query")" + local url="${API_BASE}/search?query=${encoded_query}&limit=${LIMIT}" + if [[ -n "$prefix" ]]; then + url="${url}&prefix=${prefix}" + fi + + local response + response=$(curl "${CURL_OPTS[@]}" "$url") || { echo "ERROR: Search request failed"; exit 1; } + + local total + total=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))") + + echo "Found ${total} icons for '${query}':" + echo "" + echo "$response" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for icon in data.get('icons', []): + print(f' {icon}') +" + echo "" + echo "Generate with: iconify_gen.sh " + echo "Preview with: iconify_gen.sh preview " +} + +list_collections() { + echo "Popular Iconify collections:" + echo "" + local resp + resp=$(curl "${CURL_OPTS[@]}" "${API_BASE}/collections") || { echo "ERROR: Failed to fetch collections list"; exit 1; } + echo "$resp" | python3 -c " +import sys, json +data = json.load(sys.stdin) +popular = ['mdi','lucide','tabler','ph','ri','carbon','solar','heroicons','bi','octicon','ion','fe','charm','ci','iconoir','basil','uil','mingcute','flowbite','mynaui'] +for k in popular: + if k in data: + v = data[k] + name = v.get('name','') + total = v.get('total',0) + print(f' {k:12s} {name} ({total} icons)') +" + echo "" + echo "Full list: https://icon-sets.iconify.design/" +} + +preview_icon() { + local icon_id="$1" + local collection="${icon_id%%:*}" + local name="${icon_id#*:}" + local url="${API_BASE}/${collection}/${name}.svg?width=136&height=136&color=%23${COLOR}" + local outfile="/tmp/iconify_preview_${collection}_${name}.svg" + + curl "${CURL_OPTS[@]}" "$url" -o "$outfile" || { echo "ERROR: Icon '${icon_id}' not found"; exit 1; } + echo "Preview SVG: ${outfile}" + echo "URL: ${url}" + + # Also convert to PNG for visual check + local pngfile="/tmp/iconify_preview_${collection}_${name}.png" + sips -s format png "$outfile" --out "$pngfile" >/dev/null 2>&1 || echo "WARNING: sips conversion failed; PNG may be incorrect" + echo "Preview PNG: ${pngfile}" +} + +generate_icon() { + local icon_id="$1" + local asset_name="$2" + shift 2 + + while [[ $# -gt 0 ]]; do + case "$1" in + --size) require_value --size "${2-}"; SIZE="$2"; shift 2 ;; + --color) require_value --color "${2-}"; COLOR="$2"; shift 2 ;; + --output) require_value --output "${2-}"; OUTPUT="$2"; shift 2 ;; + *) shift ;; + esac + done + + local collection="${icon_id%%:*}" + local name="${icon_id#*:}" + local imageset_dir="${OUTPUT}/${asset_name}.imageset" + + mkdir -p "$imageset_dir" + + echo "Generating ${asset_name} from Iconify '${icon_id}':" + + local scales=("1:${SIZE}" "2:$((SIZE * 2))" "3:$((SIZE * 3))") + + for scale_info in "${scales[@]}"; do + local scale="${scale_info%%:*}" + local px="${scale_info#*:}" + local suffix="" + [[ "$scale" != "1" ]] && suffix="@${scale}x" + + local svg_url="${API_BASE}/${collection}/${name}.svg?width=${px}&height=${px}&color=%23${COLOR}" + local svg_file="${imageset_dir}/${asset_name}${suffix}.svg" + local png_file="${imageset_dir}/${asset_name}${suffix}.png" + + curl "${CURL_OPTS[@]}" "$svg_url" -o "$svg_file" || { echo "ERROR: Failed to download icon '${icon_id}'"; exit 1; } + sips -s format png "$svg_file" --out "$png_file" >/dev/null 2>&1 || echo "WARNING: sips conversion may have failed for ${svg_file}" + rm "$svg_file" + + echo " ${asset_name}${suffix}.png (${px}x${px})" + done + + # Write Contents.json + cat > "${imageset_dir}/Contents.json" <"; exit 1; } + search_icons "$@" + ;; + preview) + shift + [[ $# -eq 0 ]] && { echo "Usage: iconify_gen.sh preview "; exit 1; } + preview_icon "$1" + ;; + collections) + list_collections + ;; + *) + [[ $# -lt 2 ]] && { echo "Usage: iconify_gen.sh [options]"; exit 1; } + generate_icon "$@" + ;; +esac