agentleFS
tarikhamiltontarikhamilton/claude-skills/skills/app-icon-grid/SKILL.md

app-icon-grid

Skill0 starsChanged 5 months agoMITView on GitHub

Build a single icon PNG that is a 2×2 (or NxN) grid of app/brand icons — one tile per app. Use when the user wants a Stream Deck button or similar tile whose content is multiple app icons (e.g. "communication" tile showing Messages + Slack + Gmail + X). Trigger phrases - "grid of app icons", "icon with multiple apps", "2x2 grid", "stream deck button with multiple apps inside", "make a tile with [list of apps]".

The file

Files
SKILL.md

App icon grid

Build a 288×288 PNG (or any square size) whose visual is a grid of brand-app tiles. Each tile IS the app — fills its quadrant with the brand's identity (colored background + glyph). No outer padding around the glyph inside its tile.

This is the recipe that took 5+ iterations the first time we built one (Messages/Slack/Gmail/X for the workspace 2 communication tile). The lessons below are the ones that mattered.

When this vs. other icon skills

  • Single-image branded icon (one chat bubble, one rocket) → gemini-icon
  • Single primitive (arrow, refresh, settings) → svg-icon-render
  • Multiple brand icons composited into one tile → this skill
  • Full Stream Deck button update (manifest + icons) → streamdeck-grid-update (will call into this for the icon)

Hard rules from the iteration loop

  1. For apps with their own full iOS icon (Messages, Slack, Spotify, etc.) — use the source PNG AS-IS. Don't extract the glyph, don't redraw it. The user wants to recognize the real Apple Messages icon, not a synthetic one. Glyph extraction from a full iOS icon is fragile (color-key fails on anti-aliased edges; you end up with weird half-bubbles).
  2. For glyph-only sources (Gmail M on transparent, X SVG) — composite the glyph onto a solid-color brand tile. Glyph fills ~88% of the tile by default, but TUNE PER GLYPH. Some glyphs (X) are geometrically large and read better at 0.60. Always preview.
  3. Don't draw the glyph yourself. The user will reject hand-drawn bubbles even if they look "close enough." Find the real asset.
  4. The TILES are the icons, not nested mini-icons. The user's repeated correction: each tile's brand identity (color + glyph) fills the tile edge-to-edge. No iOS-rounded-square frame INSIDE the tile holding a smaller glyph centered with padding. The tile itself IS the iOS-style rounded square.
  5. Outer rounded corners match whipsocial: 19px on 288 (≈7%). Inner tile rounded corners use iOS standard 22% of tile width.
  6. Tiles butt edge-to-edge OR have a small gap (~12px) showing the dark background through. Reference: macOS workspace.png icon (2×2 of light-gray rounded tiles on dark bg).

Sourcing the 4 (or N) app icons

  • Apple/system apps (Messages, Notes, Calendar, Mail.app, etc.): /System/Applications/<Name>.app/Contents/Resources/AppIcon.icns → sips -s format png -z 256 256 <icns> --out <out.png>. Confirm path with /usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" <Info.plist> — sometimes the icon file isn't AppIcon.icns.
  • Installed third-party apps with their own bundle (Slack, Spotify, etc.): /Applications/<Name>.app/Contents/Resources/AppIcon.icns (or similar). Slack's is at /Applications/Slack.app/Contents/Resources/electron.icns — Electron apps may use that name.
  • Web apps / not installed (Gmail, X, etc.): the brand glyph from a CDN.
    • Simple Icons (mono): https://cdn.simpleicons.org/<slug> returns SVG. Use only for icons that ARE monochrome (X). For colorful brand glyphs (Gmail M, Slack hash), use Wikimedia Commons SVGs or fetch the official asset.
    • Gmail M: https://www.google.com/gmail/about/static/images/logo-gmail.png returns the colorful M on transparent.
    • X: https://cdn.simpleicons.org/x/000000 for black-on-transparent, then render via qlmanage.
    • Render SVG → PNG with qlmanage -t -s 256 -o <outdir> <svg> (macOS built-in; no rsvg/cairo needed).

The build recipe (PIL)

The working reference implementation is /tmp/build-w2-grid-v2.py (committed style; copy and adapt for new grids). Key shape:

from pathlib import Path
from PIL import Image, ImageDraw

W = 288                  # final canvas
OUTER_PAD = 12           # padding from canvas edge to first tile
GAP = 12                 # gap between tiles
TILE = (W - 2 * OUTER_PAD - GAP) // 2   # 126 for 2x2
TILE_RADIUS = int(TILE * 0.22)          # iOS-standard tile corner
DARK_BG = (24, 24, 26, 255)             # workspace.png-matching dark
OUTER_CORNER = 19                       # whipsocial-matching outer rounded

canvas = Image.new("RGBA", (W, W), DARK_BG)


def place_full_iOS_icon(canvas, src_path, col, row):
    """For apps whose source PNG IS the iOS app icon (Messages, Slack).
    Crop to content, resize to tile, paste. No glyph extraction."""
    icon = Image.open(src_path).convert("RGBA")
    bbox = icon.getbbox()
    if bbox: icon = icon.crop(bbox)
    tile = icon.resize((TILE, TILE), Image.LANCZOS)
    x = OUTER_PAD + col * (TILE + GAP)
    y = OUTER_PAD + row * (TILE + GAP)
    canvas.alpha_composite(tile, (x, y))


def place_glyph_on_color(canvas, glyph_path, bg_color, col, row,
                        glyph_scale=0.88, invert_to_white=False):
    """For glyph-only sources (Gmail M, X SVG). Build a solid-color tile,
    composite the glyph centered at glyph_scale of tile width."""
    tile = Image.new("RGBA", (TILE, TILE), bg_color)
    glyph = Image.open(glyph_path).convert("RGBA")

    if invert_to_white:
        # Glyph from qlmanage-rendered SVG comes as dark-on-white-opaque.
        # Drop white → transparent, then invert luminance so the glyph
        # is WHITE on transparent (suitable for a dark tile).
        px = glyph.load()
        for y_ in range(glyph.height):
            for x_ in range(glyph.width):
                r, g, b, a = px[x_, y_]
                if r > 240 and g > 240 and b > 240:
                    px[x_, y_] = (0, 0, 0, 0)
                else:
                    L = (r + g + b) // 3
                    px[x_, y_] = (255 - L, 255 - L, 255 - L, a if a > 0 else 255)

    bbox = glyph.getbbox()
    if bbox: glyph = glyph.crop(bbox)

    max_dim = int(TILE * glyph_scale)
    ratio = min(max_dim / glyph.width, max_dim / glyph.height)
    new_size = (int(glyph.width * ratio), int(glyph.height * ratio))
    glyph = glyph.resize(new_size, Image.LANCZOS)
    offset = ((TILE - new_size[0]) // 2, (TILE - new_size[1]) // 2)
    tile.alpha_composite(glyph, offset)

    # iOS-style tile corner mask
    mask = Image.new("L", tile.size, 0)
    md = ImageDraw.Draw(mask)
    md.rounded_rectangle([(0, 0), tile.size], radius=TILE_RADIUS, fill=255)
    existing = tile.split()[3]
    final_alpha = Image.new("L", tile.size, 0)
    for y_ in range(tile.size[1]):
        for x_ in range(tile.size[0]):
            final_alpha.putpixel((x_, y_), min(existing.getpixel((x_, y_)),
                                              mask.getpixel((x_, y_))))
    tile.putalpha(final_alpha)
    x = OUTER_PAD + col * (TILE + GAP)
    y = OUTER_PAD + row * (TILE + GAP)
    canvas.alpha_composite(tile, (x, y))


# Compose
place_full_iOS_icon(canvas, "/path/messages.png", 0, 0)
place_full_iOS_icon(canvas, "/path/slack.png", 1, 0)
place_glyph_on_color(canvas, "/path/gmail.png", (255,255,255,255), 0, 1, glyph_scale=0.78)
place_glyph_on_color(canvas, "/path/x.png", (0,0,0,255), 1, 1, glyph_scale=0.60, invert_to_white=True)

# Outer rounded corners
mask = Image.new("L", canvas.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([(0, 0), canvas.size], radius=OUTER_CORNER, fill=255)
canvas.putalpha(mask)
canvas.save("/output.png", "PNG")

Per-glyph scale defaults (tuned the hard way)

Glyph typeSuggested glyph_scaleReason
Logo with own padding (favicon, square brand mark)0.88reads big, matches "full iOS icon" feel
Tall/wide letterform (Gmail M, single capital letter)0.75–0.80thick letters fill visually heavier than their bbox
Geometric glyph that touches its bbox (X, +, ×)0.55–0.65bbox = visual extent; glyph_scale becomes literal fill ratio
Speech bubble, complex compound shape0.85–0.92edge complexity makes it read smaller than bbox

Always preview after first build and adjust. A bbox-tight glyph at 0.88 looks crammed; a wide-margin glyph at 0.88 looks small. There's no universal number.

Anti-patterns (don't repeat)

  • ❌ Centering each full iOS app icon inside a tile background → "nested icons" look. Either the icon IS the tile (no extra wrapping) or the tile is solid-color holding a glyph.
  • ❌ Trying to color-key extract a glyph from a complex iOS icon (the bubble out of the Messages icon, the hash out of the Slack icon). Anti-aliased edges + soft gradients = ragged extraction. Use the full icon instead.
  • ❌ Hand-drawing a "close enough" version of a brand glyph in PIL primitives when you couldn't extract the source. The user will reject it. Find the real asset.
  • ❌ Fixed glyph_scale for all tiles. Per-glyph tuning is required.
  • ❌ Forgetting the outer corner mask. The grid icon needs to match whipsocial.png's ~19px corner radius for visual consistency on the Stream Deck.
  • ❌ Building without consulting the user mid-loop. After the first try shows nested-icon look, ASK what "tile IS the icon" means before iterating further — the user's intent is hard to infer from prose alone.

Cross-references

  • streamdeck-grid-update — the broader Stream Deck button skill. Calls into this skill for the icon-content step when the target button is an app-grid tile.
  • gemini-icon — for single illustrated/iOS-style icons (rocket, chat-bubble, video-camera).
  • svg-icon-render — for primitive utility icons (arrows, refresh).
  • ~/.config/aerospace/icons/whipsocial.png — reference for the 19px outer corner radius used across all Stream Deck branded icons.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.