mcpbeat Sign in

Material Symbols Icon System Agent Skill

Ship every icon as a generated ImageVector extension property on one receiver object, fetched from the icon font's own Compose generator, so the bytecode shrinker can drop the ones you never reference. Covers the generator request and its axis parameters, the edits that turn a generated file into an extension property, the filled/unfilled pairing for state icons, and which icons must stay as drawable resources. Use when adding or replacing an icon, when a set has drifted into mismatched weights and corner styles, or when a type error reports an ImageVector where a Painter was expected.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill material-symbols-icon-system

What it tells the agent to use

found in the instruction text
Grep reads your files

The instruction itself

4 sections, as written by the author

A generated icon set as extension properties

One file per icon, one extension property per file, all hanging off a single empty receiver

object you own:

// adapted — this is the generated file, after the four edits below
package com.example.app.ui.icon

@Suppress("CheckReturnValue")
val AppIcons.ArrowForwardIos: ImageVector
  get() {
    if (_ArrowForwardIos != null) return _ArrowForwardIos!!
    _ArrowForwardIos = ImageVector.Builder(
        name = "ArrowForwardIos",
        defaultWidth = 24.dp, defaultHeight = 24.dp,
        viewportWidth = 24f, viewportHeight = 24f,
        autoMirror = true,
      ).apply { path(fill = SolidColor(Color.Black), …) { … } }.build()
    return _ArrowForwardIos!!
  }

private var _ArrowForwardIos: ImageVector? = null

AppIcons is yours to name; it is an object with no members. Call sites read

AppIcons.ArrowForwardIos, mirroring how the framework exposes Icons.Rounded.* — but nothing is

pulled in that you did not ask for. Note the generated fill: opaque black, which is what makes

the tinting trap below unavoidable rather than stylistic.

Fetching one

The font's own service renders the Compose source directly, so there is no SVG-to-vector step:

curl -sfL --compressed \
  "https://fonts.gstatic.com/render/v1/Material+Symbols+Rounded/24dp/<symbol_name>.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,1,0,50" \
  -o <PascalName>.kt

Then four edits: repoint the package; change public val <symbol_name> to

val AppIcons.<PascalName>; rename the backing field _<symbol_name> and the name = string to

match; add autoMirror = true inside the builder for glyphs that must flip in a right-to-left layout

(arrows, sort, ordered lists).

Keep the axis values byte-identical across the whole set — optical size, weight, grade, roundness.

They are what makes a hundred unrelated glyphs read as one family, and one icon fetched at a

different weight is visible at a glance beside its neighbours.

Traps

The extension property is what lets the shrinker drop unused icons — never aggregate them. Each

val AppIcons.X compiles to its own static accessor, reachable only through direct references to

*that* accessor, so an icon no call site names is referenced by nothing and R8/ProGuard drops it,

vector data included. A mapOf("play" to …) or a when (name) { … } returning icons references

*every* accessor from one live method, so the whole set becomes reachable and ships. This is the one

refactor that looks like tidying and is not.

Every icon needs its own import. An extension property is resolved by import, not by receiver —

importing the AppIcons object alone leaves AppIcons.PlayArrow unresolved. That is a compile-time

resolution rule, distinct from the shrinker's reachability rule above, and it happens to push the

same way: naming each icon at each call site is what an aggregate would take away.

Image does not tint; Icon does. Both accept an ImageVector and both compile, so the choice

looks like taste. It is not: Icon tints from LocalContentColor, while Image draws the authored

colour — opaque black, as the generated file above shows — so on a dark surface it is an invisible

icon with no warning anywhere. This is the migration's characteristic late defect: a drawable carried

its own fill, so Image was correct for it, and swapping in a monochrome vector removes the colour

while nothing else on the line changes. Expect them in batches — one sweep found a whole sheet file's

worth in a single commit — and expect them to pass review, since the diff only replaced an argument.

Step 5 below counts yours. Default to Icon; where Image is genuinely wanted, name the tint:

Image(imageVector = AppIcons.Delete, contentDescription = "Delete",   // adapted
      colorFilter = ColorFilter.tint(surfaceColors().content))

ImageVector is not a Painter. Icon and Image have overloads for both, which is why the

common cases compile and hide the distinction. These do not, and each needs rememberVectorPainter:

  • an async image loader's placeholder = / error = slots
  • anything drawn inside a DrawScope (with(painter) { draw(size) })
  • any composable of your own whose parameter is typed Painter

Do not bulk-replace painterResource(Res.drawable.X) with a regex. The same pattern appears

inside Painter-typed arguments, which then fail to type-check, and a multi-line

painterResource(\n Res.drawable.X,\n) becomes the valid-but-wrong painterResource(AppIcons.X).

Use the unfilled variant only for the "off" half of a state pair. Fetch with FILL=1 by default;

refetch with FILL=0 for the outline member of a pair (FavoriteBorder, AddCircleOutline,

DownloadForOfflineOutlined). At FILL=1 both halves render identically and the distinction goes.

Do not convert an icon whose colour carries meaning. A generated symbol is monochrome and gets

tinted by the caller; a resource whose fill colour *is* the state — a blue "downloaded" tick, a red

"liked" heart — has none. Those stay drawable resources, as do logos and bitmap placeholders.

Confirm the symbol name before assuming a mapping. The authority is the codepoint list shipped

beside the variable font — for Material Symbols, google/material-design-icons, file

variablefont/MaterialSymbolsRounded[…].codepoints (the bracket spells the axes), one

name codepoint pair per line, so grep answers it. Legacy names such as favorite_border still

resolve; plausible ones like person_add_alt_1 do not.

The response is gzip-encoded even when the request asks for identity. Use curl --compressed, or

sniff the \x1f\x8b magic bytes and inflate in a script — otherwise the .kt on disk is binary.

Verifying it

All read-only; run from the repository root, substituting your icon package path and receiver-object

name for ui/icon and AppIcons.

  • One extension property per file. Only the receiver-object declaration should be listed:

find . -path '*/ui/icon/*.kt' -exec grep -L '^val AppIcons\.' {} +

→ observed: a single hit, the object declaration file.

  • No axis drift. Every generated icon carries the same nominal size:

find . -path '*/ui/icon/*.kt' -exec grep -Hn 'defaultWidth\|defaultHeight' {} + | grep -v '24\.dp'

→ observed: empty.

  • Nothing aggregates the set.

find . -path '*/ui/icon/*.kt' -exec grep -Hn 'mapOf(\|when (' {} +

→ observed: empty. A hit here means the shrinker is now keeping every icon.

  • No ImageVector in a Painter slot.

grep -rn 'placeholder = AppIcons\.\|error = AppIcons\.\|painterResource(AppIcons\.' --include='*.kt' .

→ observed: empty.

  • Every Image carrying a vector names its tint. Count the calls, and how many omit one:
   grep -rn -A6 'Image($' --include='*.kt' . | grep -v '/build/' | awk '
     /imageVector/ { v=1 } /colorFilter/ { f=1 }
     /^--$/ { if (v) { n++; if (!f) u++ }; v=0; f=0 }
     END { if (v) { n++; if (!f) u++ }; printf "Image(vector): %d, untinted: %d\n", n+0, u+0 }'

Read each untinted one against the surface it sits on; it is fine only where that surface is

light. A non-zero count right after a migration is where the invisible icons are.

To confirm the shrinker actually benefits, build with shrinking on and list the retained icon

classes in the mapping output — see r8-proguard-desktop-survival for reading that output.

How to use it

Copy the folder

Take maxrave-dev/material-symbols-icon-system from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.