|
| 1 | +--- |
| 2 | +name: assets-optimization |
| 3 | +description: Audit and optimize every image asset shipped with AltTab. Apply the right format per asset class (PDF for vectors, HEIC for raster) and the right post-processing (strip Figma cruft from PDFs, extract SF Symbols as minimal vector PDFs, encode raster sources to HEIC at q50 with visual review). Use whenever new assets are added, when the bundle size needs shrinking, or whenever you want a full assets audit. |
| 4 | +--- |
| 5 | + |
| 6 | +# /assets-optimization — AltTab asset audit and optimization |
| 7 | + |
| 8 | +## Goal |
| 9 | + |
| 10 | +Every byte that ships in `AltTab.app/Contents/Resources/` should justify itself. Vectors stay vector, rasters compress to HEIC, and neither carries metadata, color profiles, accessibility tags, or producer signatures that AppKit doesn't use. |
| 11 | + |
| 12 | +This skill applies a known-good pipeline to each asset class. It is opinionated about the right format and the right encoder for each kind of content. |
| 13 | + |
| 14 | +## When to use |
| 15 | + |
| 16 | +- A designer drops new exports into `~/Desktop/` or `resources/`. |
| 17 | +- Someone asks "why is the bundle so big?". |
| 18 | +- After adding a new icon, illustration, app icon variant, or menubar variant. |
| 19 | +- Periodic audit when nothing else is broken. |
| 20 | + |
| 21 | +## Step 1: Inventory |
| 22 | + |
| 23 | +Run a one-shot enumeration so you know what you're working with: |
| 24 | + |
| 25 | +```sh |
| 26 | +find resources -type f \( -iname '*.pdf' -o -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.heic' -o -iname '*.svg' -o -iname '*.icns' \) \ |
| 27 | + -exec ls -la {} \; | awk '{printf "%8d %s\n", $5, $9}' | sort -k2 |
| 28 | +``` |
| 29 | + |
| 30 | +Group what you see by directory. For AltTab the relevant buckets are: |
| 31 | + |
| 32 | +- `resources/icons/menubar/` — small template icons shown in the macOS menubar. |
| 33 | +- `resources/icons/tabs/` — Preferences sidebar icons (template, sized ~13pt). |
| 34 | +- `resources/icons/permission-window/` — first-launch permission window icons (~32pt). |
| 35 | +- `resources/icons/app/` — the macOS app icon (`.icns` + `.iconset/`). **Don't touch** — `.icns` is required by the bundle and Apple's tooling produces near-optimal output already. |
| 36 | +- `resources/illustrations/` — the appearance-tab preview thumbnails. Raster (screenshots inside). |
| 37 | + |
| 38 | +For each asset, decide what category it falls into: |
| 39 | + |
| 40 | +| Source content | Right format | Why | |
| 41 | +|---|---|---| |
| 42 | +| Custom vector design (Figma/Sketch/Illustrator) | **PDF** | macOS 10.13 doesn't accept SVG; PDF is the universal vector container AppKit reads natively. | |
| 43 | +| SF Symbol (Apple system glyph) | **Font glyph** in the bundled `SF-Pro-Text-Regular.otf` subset | Render via `NSImage.fromSymbol(.foo, pointSize:)` (or as text via the `Symbols` enum). Smaller than per-icon PDFs, picks up Apple's latest glyph refinements automatically when the developer updates SF Symbols.app. | |
| 44 | +| Photographic / screenshot-heavy | **HEIC** | HEIC at q50 beats JPEG by ~30% at the same perceptual quality. | |
| 45 | +| Tiny pixel-precise UI sprite | **PNG @2x** | Below ~40×40px the PDF overhead exceeds the bitmap savings. PNG wins. | |
| 46 | +| App icon (the macOS bundle one) | **`.icns`** | Required by `CFBundleIconFile`. | |
| 47 | + |
| 48 | +If an asset is in the wrong format, flag it. If it's in the right format but unoptimized, run the matching pipeline below. |
| 49 | + |
| 50 | +## Step 2: Vector PDFs — Figma exports |
| 51 | + |
| 52 | +Figma's "Export → PDF" output is bloated. For each menubar/illustration/icon vector PDF that came from Figma, you can strip ~50–75% of the bytes without losing a single rendered pixel. |
| 53 | + |
| 54 | +What Figma adds that AppKit doesn't need: |
| 55 | + |
| 56 | +1. **Embedded ICC color profile** (`/ICCBased ...`, ~3.2 KB compressed). Replace every `[/ICCBased N R]` reference with `/DeviceRGB` (or `/DeviceGray` for monochrome). Patches needed in: |
| 57 | + - the page `Resources/ColorSpace` dict |
| 58 | + - every Form XObject's `Resources/ColorSpace` dict (these are streams, not plain dicts — pikepdf's `pdf.objects` will only catch them if you accept both `Dictionary` and `Stream`) |
| 59 | + - every Image XObject's direct `/ColorSpace` key |
| 60 | + - every Shading dict inside `Pattern` entries (Figma's color icons use 8+ patterns, each with its own `/ColorSpace N R` reference) |
| 61 | +2. **`/Metadata`** XMP packet (~830 B) — Figma's XML manifest. |
| 62 | +3. **`/StructTreeRoot`, `/ParentTree`, `/StructElem`** — accessibility tags ("Document" / "Part" structural roles). AppKit's PDF renderer ignores them. |
| 63 | +4. **`/Info` dict** — `Producer="Figma"`, `Title="Menubar 22x22@1x white"`. In Figma's exports the Info dict sometimes lives **inline inside the Catalog** rather than at the trailer level, so deleting `pdf.docinfo` isn't enough — also `del root[Name('/Info')]`. |
| 64 | +5. **`/Lang`, `/MarkInfo`, `/Annots`, `/StructParents`, `/Tabs`** — empty or trivial page-level entries. |
| 65 | +6. **`/ProcSet [/PDF]`** — deprecated since PDF 1.4. |
| 66 | + |
| 67 | +Use the script: |
| 68 | + |
| 69 | +```sh |
| 70 | +python3 scripts/assets/optimize_figma_pdf.py resources/icons/menubar/*.pdf |
| 71 | +``` |
| 72 | + |
| 73 | +It edits in place and prints the savings per file. After running, also pipe through `mutool clean -ggg -z` and `qpdf --object-streams=generate --recompress-flate --compression-level=9` for the final 1–2% squeeze. |
| 74 | + |
| 75 | +Verify each file still renders by sips'ing it back to PNG and eyeballing: |
| 76 | + |
| 77 | +```sh |
| 78 | +for f in resources/icons/menubar/*.pdf; do |
| 79 | + sips -s format png "$f" --out "/tmp/$(basename $f .pdf).png" -Z 300 >/dev/null 2>&1 |
| 80 | +done |
| 81 | +``` |
| 82 | + |
| 83 | +Open the PNGs in Preview to confirm nothing visual changed. |
| 84 | + |
| 85 | +## Step 3: SF Symbols via font subset |
| 86 | + |
| 87 | +Every SF Symbol shipped in AltTab — switcher status icons, sidebar tab icons, button icons, permission/feedback icons — is rendered as a text glyph from a subsetted SF Pro Text font. There are no SF-Symbol PDFs in the bundle. |
| 88 | + |
| 89 | +How it works: SF Symbols are glyphs in the Private Use Area of SF Pro Text. Apple's `SF-Pro-Text-Regular.otf` contains every symbol they've ever shipped. We subset it down to just the codepoints AltTab needs (currently ~36 glyphs, ~17 KB) into `resources/SF-Pro-Text-Regular.otf`, and register it via `Info.plist:ATSApplicationFontsPath = ""`. At runtime, `NSFont(name: "SF Pro Text", size:)` resolves to the bundled subset on macOS <11 (where the system font isn't installed) and to the system font on macOS 11+, with identical glyph appearance either way. |
| 90 | + |
| 91 | +To add a new SF Symbol: |
| 92 | + |
| 93 | +1. Open [SF Symbols.app](https://developer.apple.com/sf-symbols/), search for the symbol, press **Cmd-C** to copy the symbol character to the clipboard. (Apple's name→codepoint mapping is not exposed via public API, and the SF Pro Text font's cmap uses `uniXXXXXX.medium`-style names rather than semantic ones, so this manual lookup is the authoritative path.) |
| 94 | +2. Paste the character into a new case on the `Symbols` enum in [src/switcher/main-window/TileFontIconView.swift](src/switcher/main-window/TileFontIconView.swift) — e.g., `case foo = "" // SF Symbol name`. |
| 95 | +3. Paste the same character at the end of the `--text=` argument in [scripts/assets/subset_font.sh](scripts/assets/subset_font.sh). |
| 96 | +4. Run `bash scripts/assets/subset_font.sh`. It reads `/Library/Fonts/SF-Pro-Text-Regular.otf` (installed by SF Symbols.app — a standard developer prerequisite) and writes the regenerated subset to `resources/SF-Pro-Text-Regular.otf`. Picks up Apple's latest glyph refinements automatically. |
| 97 | +5. Use it in code: `NSImage.fromSymbol(.foo, pointSize: 14)` returns a template `NSImage`; or `TileFontIconView(symbol: .foo, ...)` for the cached-attributed-string path in the switcher hot loop. |
| 98 | + |
| 99 | +The script runs `pyftsubset` via the project's pipenv environment. Warnings about `MERG`/`meta`/`trak` tables being dropped are normal — those tables aren't relevant to glyph rendering. |
| 100 | + |
| 101 | +### Historical note: SF Symbols via PDF (deprecated, scripts removed) |
| 102 | + |
| 103 | +A previous pipeline shipped each SF Symbol as a per-glyph PDF in `resources/icons/`. The pipeline lives only in git history now (`scripts/assets/export_sf_symbol_pdf.swift` was deleted alongside the migrated PDFs). The technique is worth knowing in case a future need arises (e.g., a multi-color symbol that fonts can't represent): |
| 104 | + |
| 105 | +- The naive route — `NSImage(systemSymbolName:).draw(in:)` against a PDF `CGContext` — produces a **black rectangle**, because Quartz emits `image-mask + fill-rectangle` operators where the rectangle paints over the mask. AppKit bug at the PDF emission level; `paletteColors` config does not fix it. |
| 106 | +- The working route was to extract the symbol's vector path directly via private selectors that have been stable across macOS 11–15: |
| 107 | + `NSImage(systemSymbolName:).representations[0] (NSSymbolImageRep) → .perform("vectorGlyph") (CUINamedVectorGlyph) → .perform("CGPath") (real CGPath)`. |
| 108 | +- The CGPath lives in CUI's internal coordinate space (~2× display points, Y-down) — scale to fit the canvas and flip Y. Walk the path via `CGPath.applyWithBlock` and emit raw PDF operators (`m`, `l`, `c`, `h`, `f`). Non-zero winding fill. Fill with DeviceGray (`0 g`), **not** `NSColor.black.cgColor`, which drags in a ~3.4 KB ICC color profile. |
| 109 | +- Final PDF wrapper: 4 objects (Catalog, Pages, Page, Content), no `/Info`, no `/Metadata`, no `/Resources/ColorSpace`. ~750–1800 bytes per icon. |
| 110 | + |
| 111 | +Git: see commit `990c1e79` ("feat: pro improve assets") for the PDF pipeline as-it-was; subsequent commit migrated the SF-Symbol PDFs back to font glyphs. |
| 112 | + |
| 113 | +## Step 4: Raster → HEIC at q50 |
| 114 | + |
| 115 | +For anything raster (illustration thumbnails, screenshots inside an icon, anything photographic), HEIC at quality 50 is the baseline. q50 is roughly 50% smaller than JPEG at perceptually-equivalent quality, and at the small display sizes used in this app the artifacts are invisible. |
| 116 | + |
| 117 | +Pipeline (built-in to macOS via `sips`): |
| 118 | + |
| 119 | +```sh |
| 120 | +sips -Z 1000 -s format heic -s formatOptions 50 input.png --out output.heic |
| 121 | +``` |
| 122 | + |
| 123 | +- `-Z 1000` resizes the longest edge to 1000px **preserving aspect ratio**. AltTab's illustration display is 500pt wide, so 1000px is the correct @2x ship size. Anything larger wastes bytes; anything smaller looks soft on Retina. |
| 124 | +- `formatOptions 50` is the quality. q50 was chosen after a side-by-side comparison at q20/q35/q50/q65/q80 — q50 was the lowest setting where text in screenshots stayed legible and gradient backgrounds didn't band. |
| 125 | + |
| 126 | +Use the script for batch conversion: |
| 127 | + |
| 128 | +```sh |
| 129 | +bash scripts/assets/encode_heic.sh ~/Desktop 1000 50 |
| 130 | +``` |
| 131 | + |
| 132 | +That walks all PNG/JPEG files in the source directory, resizes to longest-edge 1000px at q50 HEIC, writes outputs to `/tmp/heic-out/`. |
| 133 | + |
| 134 | +### Visual review (mandatory) |
| 135 | + |
| 136 | +Before swapping the new HEICs into `resources/`, **always** decode a representative sample back to PNG and visually compare against the source: |
| 137 | + |
| 138 | +```sh |
| 139 | +sips -s format png /tmp/heic-out/sample.heic --out /tmp/sample-decoded.png >/dev/null 2>&1 |
| 140 | +open /tmp/sample-decoded.png /Users/you/Desktop/sample.png |
| 141 | +``` |
| 142 | + |
| 143 | +Pick the visually most demanding file from the batch — usually one with the most text or the strongest gradients. Confirm: |
| 144 | + |
| 145 | +- No banding in flat color regions |
| 146 | +- Text edges still crisp at native display size |
| 147 | +- No haloing around anti-aliased edges |
| 148 | +- Color rendition matches |
| 149 | + |
| 150 | +If anything looks degraded, bump quality to q60 or q65 and re-batch. The user, not the script, is the final arbiter — show them the sample with sizes before committing. |
| 151 | + |
| 152 | +### Bumping quality |
| 153 | + |
| 154 | +If q50 isn't acceptable, the next quality steps are q60 and q65 — beyond that, returns diminish quickly. q80 is the previous default in this repo and roughly 2× the bytes of q50 for no visible improvement on AltTab's content. |
| 155 | + |
| 156 | +## Step 5: pbxproj registration |
| 157 | + |
| 158 | +Whenever you change the file extension of a resource (.jpg → .heic, .png → .pdf, etc.), update [alt-tab-macos.xcodeproj/project.pbxproj](alt-tab-macos.xcodeproj/project.pbxproj). The places that need patching: |
| 159 | + |
| 160 | +1. **PBXBuildFile section** — comment + the comment inside `fileRef = ... /* name.ext */`. |
| 161 | +2. **PBXFileReference section** — comment, `lastKnownFileType` (e.g. `image.pdf`, `image.heic`, `image.png`), and `path = "name.ext"`. |
| 162 | +3. **PBXGroup section** — the file's entry inside its parent group's `children`. |
| 163 | +4. **PBXResourcesBuildPhase section** — the entry in the main app target's `files`. |
| 164 | + |
| 165 | +For pure extension swaps (no new files), `sed -i '' 's|old\.ext|new.ext|g'` plus a `lastKnownFileType` substitution covers it. For new files, generate new 24-char uppercase-hex object IDs (`python3 -c "import secrets; print(secrets.token_hex(12).upper())"`) and insert in all four places. |
| 166 | + |
| 167 | +If you removed an asset entirely (file deleted from disk), delete its 4 entries from pbxproj — otherwise the build fails with "missing file" or ships dangling references. |
| 168 | + |
| 169 | +## Step 6: Verify |
| 170 | + |
| 171 | +```sh |
| 172 | +bash ai/build.sh # must show ** BUILD SUCCEEDED ** |
| 173 | +bash ai/run.sh # launch the app and visually inspect every asset |
| 174 | +``` |
| 175 | + |
| 176 | +Walk through every UI surface that loads an asset: |
| 177 | + |
| 178 | +- Menubar icon (default + the two alternates from Preferences → General → Menubar icon) |
| 179 | +- Preferences sidebar — 4 tab icons (SF Symbol on macOS 11+, bundled PDF below) |
| 180 | +- Permissions window — open by revoking a permission |
| 181 | +- Preferences → Appearance — illustration thumbnails change per show/hide row |
| 182 | + |
| 183 | +Compare `git diff --stat` before committing. Asset replacements should net negative on bundle size. |
| 184 | + |
| 185 | +## Reporting |
| 186 | + |
| 187 | +After the run, report: |
| 188 | + |
| 189 | +- File-by-file before/after sizes for everything that changed. |
| 190 | +- Total bundle delta in KB. |
| 191 | +- Any files left untouched and why (e.g., `app.icns` — bundle-required format). |
| 192 | +- The encoder settings used (especially HEIC quality if not q50, so the next person knows). |
| 193 | +- Anything the visual review revealed (e.g., "had to bump to q60 for `thumbnails_dark` because gradient banding at q50"). |
0 commit comments