Claude Skill

icon-ops

Source, vet, normalize and ship SVG icons for web UI - set selection, licence and trademark traps, currentColor theming, sprite/inline delivery, and accessibility. Triggers on: icon, icons, svg icon, find an icon, add an icon, icon set, icon library, iconify, lucide, heroicons, p

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

Full trust report

Download 0xdarkmatter-claude-mods-skills_icon-ops-3dfaf0b.zip · 35 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/icon-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

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

Skill manifest

icon-ops

Getting an icon onto a page is easy. Getting one that themes correctly, carries the right licence, matches the twelve icons beside it, and behaves for a screen reader is where the work actually is.

Helps with

An icon that won't change colour on hover, in dark mode, or when the theme switches — almost always a hardcoded #000 in the file where currentColor should be.

A UI where the icons "look off" without an obvious cause. Usually two icon sets mixed: different grid size, different stroke width, different corner language. Individually fine, together visibly wrong.

Choosing an icon set at the start of a project, when the choice is cheap, rather than after 60 icons are embedded.

Using a brand logo — GitHub, Google, a client's mark — and needing to know whether you actually may. The file licence does not answer this; trademark does.

Needing a logo for an arbitrary company that no icon set carries. That is a different category from icon sets — a runtime lookup by domain, not a committed glyph — with its own quota, caching and trademark consequences.

Icon-only buttons that a screen reader announces as "button", or announces twice. Both come from putting the accessible name in the wrong place.

Vendor SVGs carrying Inkscape/Figma metadata, fixed width/height that fights CSS, and inline styles that resist theming.

Deciding between inline SVG, a <symbol> sprite, framework components, and an icon font — and discovering too late that <img src="icon.svg"> cannot be recoloured at all.

A sprite that renders nothing in production but worked locally (the external <use> CORS trap).

Two inlined logos where the second one's gradient bleeds into the first. Both files declared id="a"; the last definition in the document wins for the whole page. Brand marks hit this constantly because they carry gradients.

Needing a mark in grey, knocked out of a dark header, or in one brand ink — and wanting to know whether to generate it or use the owner's published variant.

A logo wall where one wide wordmark dominates because everything was set to the same width.

Favicons and app icons — the modern four-file set, and why an Android maskable icon gets its edges cropped.

The core technique

fill="currentColor" is the whole game. An icon that inherits the CSS color of its context gets hover, focus, disabled, dark mode, and every future theme for free, with no icon-specific CSS. An icon with a baked-in hex breaks all of them simultaneously, and each one gets "fixed" separately later.

Everything else in this skill exists to get icons into that state and keep them there.

Workflow

1. Choose the set before sourcing anything

Lock four decisions; they constrain every icon that follows:

Decision Options
Grid 24 (most common) · 20 · 16
Family stroke · filled · both-as-matched-pair
Stroke width 1.5 · 2 — must be identical across the set
Corner language round caps/joins · square

Sensible defaults: Lucide (ISC, 24-grid, stroke 2) for a general UI, Heroicons (MIT) when you want matched outline/solid/mini tiers, Phosphor (MIT) when you need multiple weights in one family.

Reach for a second set only when the first genuinely lacks the concept — then match grid and stroke width and expect to redraw. Prefer a near-neighbour concept from your set over an exact match from a foreign one.

Full comparison table, licences, and the aggregator problem → references/icon-sources.md.

2. Vet the licence — two traps

Brand marks are trademarks regardless of file licence. Simple Icons ships brand logos under CC0, but the marks remain their owners' property. Nominative use ("Sign in with GitHub") is fine; implying endorsement, recolouring a mark to your palette, or putting it in your own logo is not. Quoting "it's CC0" as clearance is the wrong answer.

A company logo is not a UI icon. Three sources cover brand marks and they trade off reach against commitment — Simple Icons (committed, monochrome, themeable), theSVG (MIT, 6,500+ marks in brand colour via npm i thesvg, an MCP server needing no key, or npx skills add glincker/thesvg), and Brandfetch (runtime lookup by domain, any company, nothing committed; free key at developers.brandfetch.com/dashboard, hotlink-only URLs that expire in ~24h, and two free tiers that differ by 10,000x). All three carry the identical trademark position — see Brand marks. They resolve the mark for you; none of them clears it.

Aggregators hide the licence. Iconify, and any icon-search MCP or plugin, resolve across 150+ sets each keeping its own terms. Record the originating set and its licence when the icon enters the repo — one line in LICENSES.md or atop the sprite. That single line is the difference between an answerable question and an audit.

3. Normalize before it enters the repo

Vendor output is not shippable. scripts/normalize-icon.py strips editor cruft, namespaces internal ids so two inlined SVGs cannot clobber each other's gradients, drops fixed width/height so CSS controls size, and applies the correct accessibility attributes.

Colour is never guessed. A single-colour source rebinds to currentColor. A multi-colour source is refused (exit 11) until you name the treatment, because flattening a mark to a silhouette is lossy and counts as modifying it:

Flag Result
(default) mono source → currentColor
--keep-colour colours untouched — the right default for someone else's mark
--greyscale Rec.709 luminance-mapped grey
--tint '#fff' flatten to one colour; white = knockout / reverse-out
--flatten yes, really collapse a multi-colour source to currentColor

It also sanitises. An inlined SVG runs script in your page's origin; an <img src="x.svg"> does not. Since this skill tells you to inline third-party SVGs, the normalizer strips <script>, <foreignObject>, every on* handler and javascript:/data:text hrefs. Treat any SVG you did not author as untrusted input, and never inline one that has not been through this.

# Would this file change? exit 10 = yes, 0 = already clean
scripts/normalize-icon.py --check vendor.svg

# Normalize a filled icon into the repo (atomic write)
scripts/normalize-icon.py vendor.svg -o src/icons/search.svg

# A brand mark: keep its colours, just clean and namespace it
scripts/normalize-icon.py --keep-colour acme.svg -o src/logos/acme.svg

# Stroke icon: forces fill=none, stroke=currentColor, consistent caps/joins
scripts/normalize-icon.py --stroke vendor.svg -o src/icons/search.svg

# Append to a sprite as a <symbol>
scripts/normalize-icon.py --symbol --id i-search vendor.svg >> src/sprite.svg

# Machine-readable result (what changed, and why)
scripts/normalize-icon.py --json vendor.svg | jq '.data[0]'

Exit codes: 0 ok · 2 usage · 3 no such file · 4 not a usable SVG · 10 (--check only) normalization would change the file · 11 multi-colour source refused. The --check mode is a CI gate — run it over src/icons/ to keep un-normalized icons out.

For byte-level path optimisation, run SVGO after normalizing, never before:

scripts/normalize-icon.py raw.svg -o icon.svg && npx svgo --multipass icon.svg

4. Deliver

Mechanism Themeable Use when
<symbol> sprite + <use> Yes Default for a real UI — many icons, reused
Inline <svg> Yes Few icons, or per-path styling/animation
Framework component Yes Component stack already in play; tree-shakes
<img src="icon.svg"> No Never for UI icons
Icon font Colour only Legacy only — migrate, don't extend

Start a sprite from assets/sprite-template.svg, which carries the hiding pattern that survives Safari, per-symbol viewBox so mixed grids scale correctly, and the sizing rule.

Size in em, never px:

.icon { width: 1em; height: 1em; flex: none; }

1em keeps the icon optically matched to its label at every type scale. flex: none stops a flex parent squashing it into an ellipse — the most common icon layout bug there is.

The trap that only shows in production: an external <use href="/sprite.svg#id"> is CORS-blocked cross-origin and renders nothing, sometimes with no console error. Inline the sprite into the document.

Decision detail, icon-font failure modes, and optimisation order → references/inline-delivery.md.

5. Get the accessibility right — exactly two cases

Every icon is decorative or meaningful. Leaving it undecided is the defect.

<!-- Decorative: text beside it already names the control -->
<button>
  <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
  Delete
</button>

<!-- Meaningful: the icon IS the label -->
<button aria-label="Delete item">
  <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
</button>

Name the control, not the icon. The counter-intuitive part is that the SVG stays aria-hidden in both cases — a name on the icon and on the button produces a double announcement. role="img" + <title> is for standalone graphics, not for the contents of a control.

Also: icon-only controls need a 24×24 CSS px minimum interactive area (WCAG 2.2 §2.5.8) — pad the control, don't grow the glyph. Never let colour alone carry meaning: pair it with a distinct shape.

6. Variants and site icons

A mark rarely ships in one treatment. Use the owner's published mono/reversed/ greyscale asset when one exists — theirs is drawn, yours is computed, and a designer already fixed the hairline that vanishes when knocked out. Generate only when they publish none.

scripts/normalize-icon.py --tint '#fff'  acme.svg -o src/logos/acme-knockout.svg
scripts/normalize-icon.py --greyscale    acme.svg -o src/logos/acme-grey.svg

filter: grayscale(1) is right for a hover-reveal effect and wrong for a canonical asset. filter: invert(1) is never a knockout — it inverts hue too, so a blue mark comes back orange.

Logo walls: constrain both axes. width: 120px on everything makes a wide wordmark occupy ~3x the visual area of a square badge. Use max-width and max-height in a fixed box, then correct optically by eye.

Favicons are a different mark, not your logo scaled down — four files (favicon.ico, icon.svg, apple-touch-icon.png 180x180, and a separate 512x512 maskable PNG whose content sits inside the centre 80%-diameter circle).

Variant production, light/dark pairs, logo-wall sizing and logo alt conventions → references/brand-variants.md. The favicon set, the theme-aware SVG favicon, and maskable safe zones → references/favicons-and-app-icons.md.

What this skill doesn't cover

  • Duotone/tri-tone treatments, filter-based tinting of a whole set, and raster→vector tracing → svg-brand-tint-ops. This skill produces flat variants (mono, grey, knockout) of a single mark; that one does tonal re-mapping and vectorising.
  • Choosing the palette itself → color-ops
  • Illustration and generative artwork → genart-ops, isometric-ops
  • Authoring new icons — this skill sources, vets and ships existing ones

Cross-references

When Use
The icons are right but the palette isn't color-ops
A whole set needs brand recolouring or a logo needs vectorising svg-brand-tint-ops
Building the surrounding component styles tailwind-ops

References

  • references/icon-sources.md — the set comparison table (licence, grid, family, notes) for the eleven sets worth knowing; the trademark-vs-file-licence distinction for brand marks; the aggregator licence trap; MCP/plugin sourcing discipline; what each licence class actually requires by way of attribution; and brand marks — Simple Icons vs theSVG vs Brandfetch compared on shape, coverage, colour and offline behaviour, plus Brandfetch's key setup, its two very different free tiers, hotlink/expiry constraints, and both MCP servers. Load when choosing a set, sourcing a company logo, or before shipping any brand mark.

  • references/inline-delivery.md — delivery mechanism comparison and why icon fonts fail; the external-<use> CORS trap; em sizing and optical alignment; currentColor theming; the full accessibility checklist (both cases, target size, contrast, reduced motion); and SVGO ordering. Load when wiring icons into a page or debugging one that won't theme.

  • references/brand-variants.md — producing mono, greyscale, knockout and single-ink variants of a mark; why Rec.709 luminance beats an RGB average; when a CSS filter is right and when it is a lie; the three light/dark approaches and why the internal-media-query one usually breaks; logo-wall sizing by area rather than width; and logo alt conventions. Load when a mark needs a treatment it did not ship with.

  • references/favicons-and-app-icons.md — the modern four-file set and the head block that serves it, why rel="shortcut icon" is meaningless, the theme-aware SVG favicon, Android maskable safe zones, and designing a mark down to 16px. Load for favicons, PWA icons or app icons.

Scripts

  • scripts/normalize-icon.py — normalize a vendor SVG for inline themeable use. --check for a CI gate, --stroke for stroke families, --symbol --id for sprite assembly, --json for a machine-readable diff summary. Idempotent: re-running on a normalized file reports clean.

Assets

  • assets/sprite-template.svg — commented <symbol> sprite scaffold to copy into a project, carrying the Safari-safe hiding pattern, per-symbol viewBox, currentColor defaults, and both filled and stroke examples.
Files (claude-mods)
  • assets
    • sprite-template.svg 2.7 KB · in bundle
  • references
    • brand-variants.md 8 KB
      # Brand Mark Variants — mono, greyscale, knockout, light/dark
      
      Every real brand needs its mark in more than one treatment: full colour on
      white, reversed out of a dark header, greyscale on a partner wall, single-colour
      where printing allows one ink. This file covers producing those without
      wrecking the mark or your layout.
      
      Companion to [icon-sources.md](icon-sources.md) (where marks come from) and
      [inline-delivery.md](inline-delivery.md) (how any SVG reaches the page).
      
      ---
      
      ## Rule zero: use the owner's variant before you make one
      
      **Most brand guidelines already publish a mono, a reversed and a greyscale
      version.** They are drawn, not computed — a designer thickened a hairline that
      would disappear when knocked out, or removed a gradient that greyscales to mud.
      A generated variant is a *fallback for when no official one exists*, never the
      first choice.
      
      Order of preference:
      
      1. The owner's official variant from their brand/press page.
      2. A generated variant, if they publish none and your use is permitted.
      3. Nothing — use the full-colour mark on a background that suits it.
      
      This matters legally as well as visually: modifying a mark is exactly what
      trademark guidelines restrict (see
      [trap 1](icon-sources.md#1-brand-logos-are-trademarks-whatever-the-file-licence-says)).
      Generating a knockout for a dark header is normally uncontroversial and often
      explicitly permitted; recolouring a mark into *your* palette usually is not.
      
      ## The two colour worlds
      
      Everything below depends on which of these you are holding, and they behave
      completely differently:
      
      | | Mono icon / mono mark | Full-colour mark |
      |---|---|---|
      | Source | one colour, or `currentColor` | several colours, often gradients |
      | Recolour | **free** — CSS `color` drives it | needs a generated variant or a filter |
      | Knockout | `color: #fff` | generate, or use the official reversed asset |
      | Greyscale | already mono | luminance-map, or `filter: grayscale(1)` |
      
      A mono mark is a solved problem: `fill="currentColor"` and the CSS cascade does
      the rest, in every state and both themes, with no extra assets.
      
      ## Producing variants
      
      `scripts/normalize-icon.py` will not guess. A multi-colour source is **refused**
      (exit 11) until you name the treatment, because flattening a mark silently is
      both lossy and a modification:
      
      ```bash
      # Keep it exactly as published — the correct default for someone else's mark
      normalize-icon.py --keep-colour acme.svg -o src/logos/acme.svg
      
      # Knockout / reverse-out for a dark header
      normalize-icon.py --tint '#fff' acme.svg -o src/logos/acme-knockout.svg
      
      # Greyscale, Rec.709 luminance-mapped (preserves relative tonal separation)
      normalize-icon.py --greyscale acme.svg -o src/logos/acme-grey.svg
      
      # A single brand ink
      normalize-icon.py --tint '#0f172a' acme.svg -o src/logos/acme-mono.svg
      
      # Genuinely mono icon drawn with several greys — collapse to currentColor
      normalize-icon.py --flatten scruffy-icon.svg -o src/icons/thing.svg
      ```
      
      **Why luminance and not average.** Rec.709 weights green far above blue
      (0.2126R + 0.7152G + 0.0722B) because the eye does. A naive `(r+g+b)/3` renders
      a saturated blue and a saturated yellow as near-identical greys, collapsing
      exactly the contrast the mark relies on.
      
      ### The CSS alternative, and when it is wrong
      
      ```css
      .logo--grey { filter: grayscale(1); }
      .logo--grey:hover { filter: none; }          /* the partner-wall convention */
      ```
      
      A CSS filter is right for a **hover-reveal** effect: one asset, reversible, no
      extra request. It is wrong when the greyscale version is the *canonical* asset,
      because a filter cannot fix the things a designer would — a gradient that turns
      to mud, a hairline that vanishes, a light element that disappears on white.
      Generate the asset when it is the real one; filter when it is an effect.
      
      `filter: invert(1)` is **never** a knockout. It inverts hue as well as
      lightness, so a blue mark becomes orange. Use a real knockout variant.
      
      ## Light/dark pairs
      
      Dark mode rarely wants the same mark with a different colour — it often wants a
      *different asset*, because the light-mode mark may contain a light element that
      disappears. Three approaches, best first:
      
      ```html
      <!-- 1. Two assets, browser picks. Works in plain HTML, no JS, no flash. -->
      <picture>
        <source srcset="/logos/acme-dark.svg" media="(prefers-color-scheme: dark)">
        <img src="/logos/acme.svg" alt="Acme" width="120" height="32">
      </picture>
      ```
      
      ```css
      /* 2. Two elements, CSS toggles. Needed when the theme is class-driven, not
            media-driven — a user-selectable theme is not prefers-color-scheme. */
      .logo-dark { display: none; }
      [data-theme="dark"] .logo-light { display: none; }
      [data-theme="dark"] .logo-dark  { display: block; }
      ```
      
      ```xml
      <!-- 3. One SVG that adapts internally. Elegant, but ONLY works when inlined:
              a media query inside an SVG loaded via <img> evaluates against the
              image's own context and will not see the page's theme reliably. -->
      <svg viewBox="0 0 120 32">
        <style>
          .mark { fill: #0f172a; }
          @media (prefers-color-scheme: dark) { .mark { fill: #f8fafc; } }
        </style>
        <path class="mark" d="…"/>
      </svg>
      ```
      
      Approach 3 is the one people reach for and the one that most often fails, because
      the failure only appears once the SVG is moved into an `<img>` or a CSS
      background. If the mark must adapt and might be loaded as an image, use 1 or 2.
      
      ## Logo walls — size by area, not by width
      
      **The single most common logo-wall bug is `width: 120px` on everything.** Marks
      have wildly different aspect ratios: a wide wordmark set to the same width as a
      square badge occupies roughly three times the visual area and dominates a grid
      that was supposed to read as equals.
      
      Constrain both axes inside a fixed box and let each mark find its own fit:
      
      ```css
      .logo-wall { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 2rem; align-items: center; }
      .logo-wall img {
        max-width: 100%;
        max-height: 40px;      /* the real constraint for wide wordmarks */
        width: auto;
        height: auto;
        margin-inline: auto;
        display: block;
      }
      ```
      
      Then correct **optically**, not mathematically. Equal bounding boxes still read
      unequal: a circular mark looks smaller than a square one of identical height, and
      a mark with heavy strokes looks larger than a fine one. Nudge per-logo with a
      modifier class rather than pursuing a formula — this is a judgement call every
      design system ends up making by eye.
      
      Two more that bite:
      
      - **Always set `width` and `height` attributes** on logo `<img>` elements even
        when CSS overrides them; without an intrinsic ratio the grid reflows as each
        logo lands, and Cumulative Layout Shift is measured on exactly this.
      - **Give marks a consistent optical padding**, not a consistent box. Most brand
        guidelines specify clear space in terms of the mark's own geometry (e.g. "the
        height of the A"); honour that rather than a uniform CSS padding.
      
      ## Accessible names for logos
      
      The [two-case rule](inline-delivery.md#accessibility--exactly-two-cases) applies,
      with one logo-specific convention:
      
      ```html
      <!-- Home link: the mark IS the label. Name it for the company, not the file. -->
      <a href="/" aria-label="Acme, home">
        <svg class="logo" aria-hidden="true" focusable="false">…</svg>
      </a>
      
      <!-- Partner wall: the company name is the content, so it must be readable -->
      <img src="/logos/acme.svg" alt="Acme" width="120" height="32">
      
      <!-- Decorative repetition — the name is already in adjacent text -->
      <figure>
        <img src="/logos/acme.svg" alt="" width="120" height="32">
        <figcaption>Acme</figcaption>
      </figure>
      ```
      
      **Never `alt="Acme logo"`.** A screen reader already announces it as an image;
      "logo" is noise, and the useful information is the company name. `alt=""` is
      correct — and required — when the name appears in adjacent text, or the name is
      announced twice.
      
      ## Cross-reference
      
      - Where marks come from and the trademark position → [icon-sources.md](icon-sources.md)
      - Delivery, sizing, the full a11y checklist → [inline-delivery.md](inline-delivery.md)
      - Duotone/tri-tone filter treatments and raster→vector tracing → `svg-brand-tint-ops`
      - Choosing the palette a tint targets → `color-ops`
      
    • favicons-and-app-icons.md 5.4 KB
      # Favicons and App Icons
      
      The other end of icon work: the marks that represent the *site*, not the UI
      inside it. Different constraints, different failure modes, and a legacy tail
      that generates a lot of cargo-culted markup.
      
      Verified 2026-08-30.
      
      ---
      
      ## The modern minimal set
      
      Four files cover every current browser and platform. Generators that emit
      twenty-plus files are producing a 2015 answer.
      
      | File | Size | Why it exists |
      |---|---|---|
      | `favicon.ico` | 32×32 (multi-res ok) | Legacy fallback; browsers request `/favicon.ico` **even with no link tag** |
      | `icon.svg` | any (vector) | The real one — scales to every density, and can adapt to dark mode |
      | `apple-touch-icon.png` | 180×180 | iOS home screen; iOS ignores the SVG and the manifest for this |
      | `icon-512-maskable.png` | 512×512 | Android adaptive icons, via the web manifest |
      
      ```html
      <link rel="icon" href="/favicon.ico" sizes="32x32">
      <link rel="icon" href="/icon.svg" type="image/svg+xml">
      <link rel="apple-touch-icon" href="/apple-touch-icon.png">
      <link rel="manifest" href="/manifest.webmanifest">
      ```
      
      That is the whole head block. Notes on it:
      
      - **Order matters less than `type`.** A browser supporting SVG picks `icon.svg`
        because of the `type` hint; others fall back to the `.ico`.
      - **`rel="shortcut icon"` is meaningless.** "shortcut" was never a valid link
        relation; `rel="icon"` alone is correct and has been for many years.
      - **Keep `/favicon.ico` at the origin root** regardless of the link tag, because
        browsers, feed readers and link-preview crawlers request that exact path.
      - **`apple-touch-icon` must be PNG and must not be transparent** — iOS composites
        it onto a white-to-black gradient and transparency renders as black.
      
      ## The SVG favicon can follow the browser theme
      
      The one genuine advantage of the SVG favicon, and it is easy to miss: a media
      query *inside* the file works, because the browser evaluates it in the browser's
      own context, not the page's.
      
      ```xml
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
        <style>
          path { fill: #0f172a; }
          @media (prefers-color-scheme: dark) { path { fill: #f8fafc; } }
        </style>
        <path d="…"/>
      </svg>
      ```
      
      A near-black favicon vanishes into a dark browser chrome; this is the fix, and
      it costs one embedded style block. Note this is the opposite of the situation in
      page content, where [an internal media query is unreliable](brand-variants.md#lightdark-pairs)
      once the SVG is loaded through `<img>`.
      
      ## Maskable icons — the safe zone is the whole trick
      
      Android crops your icon to whatever shape the launcher uses: circle, squircle,
      rounded square, teardrop. A normal icon gets its corners — often its whole edge —
      sliced off.
      
      **The safe zone is a centred circle with a diameter of 80% of the icon.**
      Everything outside it may be cropped on some device. So for a 512×512 maskable
      icon, all meaningful content sits inside a 409px-diameter centre circle, and the
      remaining ~51px band on each side is bleed that must be *filled background*, not
      transparency.
      
      ```json
      {
        "icons": [
          { "src": "/icon-192.png",          "sizes": "192x192", "type": "image/png" },
          { "src": "/icon-512.png",          "sizes": "512x512", "type": "image/png" },
          { "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png",
            "purpose": "maskable" }
        ]
      }
      ```
      
      Ship the maskable variant **as a separate file**. Declaring one icon with
      `"purpose": "any maskable"` forces a single artwork to serve both, so it is
      either over-padded when used un-cropped or clipped when used masked. Two files,
      two purposes.
      
      ## Designing the mark down
      
      A favicon is 16 CSS px in a tab. That is not a small logo — it is a different
      mark, and the most common mistake is shipping the full wordmark scaled down to
      an illegible smudge.
      
      - **Use the symbol, not the wordmark.** If the brand has no symbol, use a single
        letterform.
      - **Reduce detail deliberately.** Strokes that read at 200px disappear at 16px;
        thicken them in the favicon artwork rather than trusting the scale.
      - **Test at true size against real chrome**, light and dark, with several tabs
        open. A mark that is distinctive alone can be indistinguishable from its
        neighbours in a crowded tab strip.
      - **Contrast against browser chrome**, not against your site. The tab background
        is the browser's colour, and it changes with the user's theme.
      
      ## Producing them
      
      Favicon generation is a raster pipeline, not an SVG-normalisation job, so it
      sits outside `normalize-icon.py`. Do the source cleanup here, the rasterising
      elsewhere:
      
      ```bash
      # 1. Normalize the source mark (keep its colours — this is the brand asset)
      normalize-icon.py --keep-colour brand-symbol.svg -o public/icon.svg
      
      # 2. Rasterise. Any of ImageMagick / sharp / rsvg-convert; sizes above.
      #    Bake the background INTO the maskable PNG - transparency is not bleed.
      ```
      
      Two constraints worth carrying into whichever tool does the rasterising:
      
      - **`icon.svg` keeps its `viewBox` and stays square.** A non-square favicon is
        letterboxed unpredictably.
      - **Strip the `aria-hidden`/`focusable` attributes** that `normalize-icon.py`
        adds for inline use — harmless in a favicon, but meaningless, and their
        presence suggests the file was copied from the UI icon set rather than
        authored for this.
      
      ## Cross-reference
      
      - Normalising and namespacing SVGs → `scripts/normalize-icon.py`
      - Mono / knockout / greyscale variants of a mark → [brand-variants.md](brand-variants.md)
      - Where brand marks come from → [icon-sources.md](icon-sources.md)
      
    • icon-sources.md 10.8 KB
      # Icon Sources and Licensing
      
      Where to get icons, and the licence traps that matter when the work ships.
      
      ---
      
      ## Pick one set and stay in it
      
      **The single biggest quality tell in an interface is mixed icon sets.** Icons are
      drawn to a house grid, stroke weight and corner language; two sets side by side
      read as broken even when each is individually excellent. Before sourcing
      anything, decide:
      
      | Decision | Why it locks everything downstream |
      |---|---|
      | **Grid size** (24 / 20 / 16) | Determines optical density. A 16-grid icon scaled to 24 looks thin and under-detailed |
      | **Family** (stroke vs filled) | Mixing them within one UI region reads as inconsistent state, not variety |
      | **Stroke width** (1.5 vs 2) | The most visible mismatch of all. Non-negotiable across a set |
      | **Corner language** (round vs square caps) | Subtle alone, obvious in a toolbar row |
      
      Only source from a second set when the first genuinely lacks a concept — then
      match grid and stroke width, and expect to redraw.
      
      ## The sets worth knowing
      
      Licences verified 2026-08-30. **Licences do change** — confirm at the source
      before shipping client work.
      
      | Set | Licence | Grid | Family | Notes |
      |---|---|---|---|---|
      | **Lucide** | ISC | 24 | stroke 2 | Community fork of Feather, far larger. Good default |
      | **Feather** | MIT | 24 | stroke 2 | Small, very consistent, largely static |
      | **Heroicons** | MIT | 24 / 20 / 16 | both | Tailwind Labs. Ships outline + solid + mini as matched sets |
      | **Phosphor** | MIT | 16-based | 6 weights | Widest weight range; thin→fill in one family |
      | **Tabler** | MIT | 24 | stroke 2 | Very large set, consistent |
      | **Bootstrap Icons** | MIT | 16 | both | Pairs with Bootstrap's type scale |
      | **Material Symbols** | Apache 2.0 | 24 | variable font | Axes for weight/fill/grade. Google house style |
      | **Remix Icon** | Apache 2.0 | 24 | both | Matched outline/fill pairs |
      | **Octicons** | MIT | 16 / 24 | filled | GitHub house style |
      | **Font Awesome Free** | Icons **CC BY 4.0**, fonts OFL 1.1, code MIT | varies | both | **CC BY requires attribution.** Pro tier is paid |
      | **Simple Icons** | CC0 1.0 (files) | 24 | filled | Brand marks — see [Brand marks](#brand-marks--three-sources-one-trademark-position) |
      
      ## The two traps
      
      ### 1. Brand logos are trademarks, whatever the file licence says
      
      **Simple Icons releases the SVG files under CC0, but the trademarks they depict
      remain the property of their owners.** A permissive file licence is not
      permission to use a company's mark. In practice:
      
      - **Fine:** "Sign in with GitHub" next to the GitHub mark — nominative use,
        describing a real integration.
      - **Not fine:** a brand's logo in a testimonial, comparison or customer wall
        implying a relationship that does not exist; any modification of the mark
        (recolouring a logo to your palette is a modification); using a mark in your
        own logo, favicon or app icon.
      - Many owners publish brand guidelines with clearances, minimum sizes and
        prohibited treatments. For anything client-facing, follow those, not the
        icon-set licence.
      
      This is the one place where "the licence says CC0" is an actively misleading
      answer, so state the trademark position explicitly rather than quoting CC0.
      
      ### 2. Aggregators hide the licence
      
      **Iconify** exposes 200,000+ icons across 150+ sets behind one API. Iconify's own
      code is MIT — **each icon set keeps its own licence**, and the aggregation is
      exactly what makes it easy to ship a CC BY icon with no attribution, or a brand
      mark you had no right to use.
      
      The same applies to any MCP server, plugin, or design-tool plugin that searches
      across sets: the search result is a file, not a clearance.
      
      **Rule: resolve the icon back to its originating set and record that set's
      licence before the icon enters the repo.** One line in the commit message or a
      `LICENSES.md` row is enough, and it is the difference between an answerable
      question and an audit.
      
      ## Brand marks — three sources, one trademark position
      
      A company's logo is not a UI icon and does not come from a UI icon set. Three
      sources cover it, in increasing order of reach and commitment:
      
      | Source | Shape | Coverage | Colour | Offline |
      |---|---|---|---|---|
      | **Simple Icons** | committed SVG files | ~3k brands | monochrome, themeable | yes |
      | **theSVG** | npm package + MCP + Agent Skill | **6,500+** brands | brand colour | yes |
      | **Brandfetch** | runtime API keyed on domain | **any domain** | brand colour | **no** |
      
      **Every one of them ships the same legal position**, and each says so in its own
      words: theSVG's tooling is MIT while "the brand icons themselves remain the
      intellectual property of their respective trademark holders"; Simple Icons
      releases files under CC0 with the marks still owned. Reach and convenience vary;
      clearance does not exist in any of them.
      
      ### theSVG — brand marks as a dependency
      
      Open source ([glincker/thesvg](https://github.com/glincker/thesvg), MIT) with
      three delivery routes onto the same 6,500+ brand catalogue:
      
      ```bash
      npm i thesvg                       # tree-shakeable, typed components
      npx -y @thesvg/mcp-server          # MCP server; binary is `thesvg-mcp` (MIT)
      npx skills add glincker/thesvg     # Agent Skill via skills.sh
      ```
      
      The MCP route gives an agent search → preview → fetch as tool calls without
      leaving the editor, and needs no API key — a real advantage over Brandfetch for
      agent work, where Brandfetch's MCP burns the 100/month Brand API quota.
      
      **Prefer theSVG over Brandfetch whenever the brand set is known at build time.**
      You get a committed, offline-safe, versioned asset instead of a runtime
      dependency with an expiring URL.
      
      Tooling is MIT. **The marks are not** — theSVG says so itself: the brand icons
      "remain the intellectual property of their respective trademark holders", with
      an explicit instruction to check each brand's usage guidelines before commercial
      use. That is the same position as Simple Icons and Brandfetch, stated plainly.
      
      ### Brandfetch — runtime lookup by domain
      
      Where the other two carry a *fixed catalogue*, **Brandfetch** resolves a
      company's real brand assets from its **domain** at runtime — full colour, any
      company, nothing committed. It answers what no catalogue can: a client whose
      logo was never in any set.
      
      Treat it as a separate class, because it behaves like one:
      
      | | Icon set / theSVG | Brandfetch |
      |---|---|---|
      | Resolved | Build time, committed | **Runtime**, remote |
      | Keyed on | Concept ("search") | **Domain** ("nike.com") |
      | Colour | Monochrome, `currentColor` | Full brand colour, **not themeable** |
      | Coverage | Fixed set | Any domain |
      | Offline | Works | **Fails** |
      
      Because it is delivered as a remote image it sits in the `<img>` row of the
      [delivery matrix](inline-delivery.md) — it cannot inherit `color`, cannot be
      recoloured, and adds a third-party runtime dependency to your page. Correct for
      a customer logo wall; never for UI iconography.
      
      ### Getting a key
      
      Every Brandfetch product needs a client ID or token. Signup is free:
      **https://developers.brandfetch.com/dashboard** (keys live under *Keys and MCP*).
      
      Store it in an environment variable or your secret manager and **never commit
      it** — same rule as any other API credential. It is a client-side identifier in
      CDN URLs, so treat it as attributable-but-not-secret: rotate it if abused, and
      still keep it out of the repo.
      
      ### The quota trap — "Brandfetch is free" means two different things
      
      Verified against Brandfetch's own docs 2026-08-30. The free tiers differ by an
      order of magnitude *by product*, and assuming the generous one is how you get
      throttled on day two:
      
      | Product | Free allowance |
      |---|---|
      | **Logo API** (CDN logo by domain) | Fair use ~1M req/month; 1,000 per 5 min per IP |
      | **Brand Search API** (autocomplete) | Free |
      | **Brand API / MCP server** | **100 requests per month** |
      
      Both the Logo API and Brand Search API docs state plainly that **no attribution
      is required**. Several third-party comparison pages claim a "Powered by
      Brandfetch" link is mandatory — at least one of them is a competitor's landing
      page. The official docs win, but re-check before relying on it commercially.
      
      ### Operational constraints
      
      - **Hotlink; do not cache or commit.** Logo image URLs expire after ~24 hours.
        Downloading one into `src/icons/` produces an asset that breaks the next day.
      - **Fair use forbids replicating their product** — building a standalone brand
        autocomplete on the Search API is out; embedding it in a larger product is fine.
      - **Offline and air-gapped builds fail.** If the page must render without
        network, commit a static asset instead and accept the staleness.
      
      ### MCP server
      
      `https://mcp.brandfetch.io/mcp` — OAuth in interactive clients, or a bearer
      token from the dashboard for headless use. Tools: `brand_search`, `get_brand`,
      `get_brand_context`, `enrich_transaction`, `build_logo_urls`, `send_feedback`.
      
      Useful when an agent needs to resolve a brand mid-task. Two cautions: **every
      MCP call consumes the Brand API quota** — the 100/month tier, not the 1M Logo
      API one — and `build_logo_urls` constructs CDN URLs *without* spending a call,
      so prefer it when you only need the URL.
      
      ### The trademark position is unchanged
      
      **An API serving you a logo is not permission to use it.** Brandfetch's fair-use
      policy governs *their service*; it says nothing about the mark owner's rights.
      Everything in trap 1 above applies identically to a logo fetched by domain — the
      convenience of resolution is not clearance.
      
      ## Sourcing via MCP or a plugin
      
      When an icon-search MCP server is available — `@thesvg/mcp-server` for brand
      marks, Brandfetch's for logos by domain, or an aggregator's — it collapses
      search → preview → fetch into one step. Two disciplines survive that
      convenience:
      
      1. **Name the originating set** for every icon you keep (trap 2).
      2. **Normalize before committing** — vendor output carries editor cruft,
         hardcoded fills and fixed `width`/`height`:
      
         ```bash
         normalize-icon.py --check fetched.svg || normalize-icon.py fetched.svg -o src/icons/search.svg
         ```
      
      Search by *concept*, not by name: "trash", "bin", "delete" and "remove" return
      different results in the same set. If the concept genuinely isn't there, prefer
      a near neighbour from the same set over an exact match from a foreign one.
      
      ## Attribution, when it is required
      
      CC BY (Font Awesome Free) needs attribution; MIT/ISC/Apache-2.0 need the licence
      text preserved but no user-visible credit; CC0 needs nothing. A single
      `LICENSES.md`, or a comment block at the top of the sprite, discharges all of
      them:
      
      ```
      Icons: Lucide (ISC) — https://lucide.dev
             Font Awesome Free 6 (CC BY 4.0) — https://fontawesome.com
      ```
      
      Put it where the icons live, not in a README nobody edits when the set changes.
      
      ## Cross-reference
      
      - Delivery mechanics (inline vs sprite vs font) and accessibility →
        [inline-delivery.md](inline-delivery.md)
      - Recolouring a whole set to a brand palette → `svg-brand-tint-ops`
      
    • inline-delivery.md 6.8 KB
      # Icon Delivery and Accessibility
      
      How an icon reaches the page, and how it behaves for people who cannot see it.
      
      ---
      
      ## Choose the delivery mechanism first
      
      It determines whether the icon can be themed at all, so it is not a late detail.
      
      | Mechanism | Themeable | Requests | Use when |
      |---|---|---|---|
      | **Inline `<svg>`** | Yes — full CSS access to every path | 0 | Few icons, or an icon needing per-part styling/animation |
      | **`<symbol>` sprite + `<use>`** | Yes — `currentColor` and CSS on the host `<svg>` | 1 | **The default for a real UI.** Many icons, each used repeatedly |
      | **Component (JSX/Vue/Svelte)** | Yes | 0 (bundled) | Component framework already in play; tree-shaking removes unused icons |
      | **`<img src="icon.svg">`** | **No** — cannot recolour, cannot inherit `color` | 1 each | Never, for UI icons. Acceptable for a fixed logo |
      | **CSS `background-image`** | **No** (short of `mask`) | 1 each | Decorative only. `mask-image` recolours but loses multi-tone |
      | **Icon font** | Colour only | 1 | Legacy systems. See the warning below |
      
      ### Why not an icon font
      
      Icon fonts map glyphs onto private-use codepoints. The failure modes are real
      and user-visible: a screen reader may announce the codepoint or nothing at all;
      a font-blocking extension or a failed font load leaves an empty box; browser
      font-substitution can render an unrelated glyph; and text-only zoom or a reader
      mode can displace it entirely. Ligature-based fonts also leak literal text into
      copy-paste. SVG has no equivalent failure mode. Migrate rather than extend.
      
      ### The external-`<use>` trap
      
      ```html
      <!-- Silently renders nothing when the sprite is on another origin -->
      <svg><use href="/assets/sprite.svg#i-search"/></svg>
      ```
      
      An external `<use>` reference is subject to CORS and is blocked cross-origin —
      including from a CDN — with no console error in some browsers. **Inline the
      sprite into the document** (near the top of `<body>`), or bundle icons as
      components. If you must reference externally, verify it on the deployed origin,
      not on localhost.
      
      ## Inline SVG is executable; `<img>` is not
      
      The delivery choice is a security boundary, not only a styling one. An **inlined**
      `<svg>` becomes part of your DOM: `<script>`, `on*` handlers and
      `javascript:` hrefs inside it run **in your page's origin**, with access to your
      cookies and storage. The same file loaded through `<img src="icon.svg">` or a CSS
      `background-image` is rendered in an isolated context where none of that executes.
      
      That is the real cost of the recommendation to inline: you inherit responsibility
      for the contents. So:
      
      - **Never inline an SVG you did not author** without sanitising it first.
        `scripts/normalize-icon.py` removes `<script>`, `<foreignObject>`, every `on*`
        attribute and `javascript:`/`vbscript:`/`data:text` hrefs, and reports the count
        as `stripped_active`.
      - **User-uploaded SVG is the dangerous case** — an avatar or logo upload is a
        stored-XSS vector the moment it is inlined. Serve untrusted SVG through `<img>`
        from a separate origin, or rasterise it.
      - A **Content-Security-Policy** without `unsafe-inline` blocks inline `<script>`
        in an SVG too, and is worth having as the second layer — but it does not cover
        every vector, so sanitise regardless.
      
      ## Sizing
      
      Size icons in `em`, not `px`:
      
      ```css
      .icon { width: 1em; height: 1em; flex: none; }
      ```
      
      `1em` ties the icon to the adjacent text size, so it stays optically matched to
      its label at every step of the type scale and through user font-size settings.
      `flex: none` stops a flex parent from squashing it into an ellipse — the single
      most common icon layout bug.
      
      For optical alignment with a text baseline, prefer flex centring on the
      container over `vertical-align` nudges:
      
      ```css
      .btn { display: inline-flex; align-items: center; gap: 0.5em; }
      ```
      
      ## Colour
      
      **`fill="currentColor"` is the whole technique.** It makes the icon inherit the
      CSS `color` of its context, so hover, focus, disabled, dark mode and theme
      switches all work with no icon-specific rules:
      
      ```css
      .btn        { color: var(--fg); }
      .btn:hover  { color: var(--fg-strong); }   /* icon follows automatically */
      ```
      
      Hardcoding a hex in the SVG breaks every one of those states at once. For stroke
      icons the same applies to `stroke="currentColor"`, and `fill` must be `none` or
      the glyph floods solid.
      
      Recolouring a whole set to a brand palette (duotone, gradients, filter-based
      tinting) is `svg-brand-tint-ops`, not this skill.
      
      ## Accessibility — exactly two cases
      
      Every icon is either decorative or meaningful. There is no third option, and
      leaving it undecided is the defect.
      
      ### Decorative — the icon repeats adjacent text
      
      ```html
      <button>
        <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
        Delete
      </button>
      ```
      
      `aria-hidden="true"` removes it from the accessibility tree; `focusable="false"`
      stops legacy IE/Edge putting the SVG in the tab order. The button already has
      its name from the visible text.
      
      ### Meaningful — the icon *is* the only label
      
      ```html
      <button aria-label="Delete item">
        <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
      </button>
      ```
      
      **Name the control, not the icon.** This is the counter-intuitive part: even
      here the SVG stays `aria-hidden`, and the accessible name goes on the `<button>`.
      A name on the icon and a name on the button produces a double announcement.
      
      Use `role="img"` plus `<title>` only when the SVG is standalone content rather
      than the contents of a control:
      
      ```html
      <svg role="img" aria-labelledby="chart-t" viewBox="0 0 24 24">
        <title id="chart-t">Revenue trending upward</title>
        ...
      </svg>
      ```
      
      ### The rest of the checklist
      
      - **Never convey status by icon shape alone** if colour is the differentiator —
        pair a colour change with a distinct shape (✓ vs ✕, not green dot vs red dot).
      - **Target size**: the interactive area of an icon-only control should be at
        least 24×24 CSS px (WCAG 2.2 §2.5.8, AA), regardless of how small the glyph is.
        Pad the control, don't grow the icon.
      - **Honour `prefers-reduced-motion`** for any animated icon (spinners excepted,
        where the motion carries the meaning).
      - **Contrast**: a meaningful icon is subject to the 3:1 non-text contrast
        requirement (WCAG 1.4.11) against its background. Decorative icons are exempt.
      
      ## Optimising
      
      `normalize-icon.py` handles the correctness pass — cruft, `currentColor`, sizing,
      a11y attributes. For byte-level path optimisation (precision reduction, path
      merging), run **SVGO** after normalizing, not before:
      
      ```bash
      normalize-icon.py raw.svg -o icon.svg && npx svgo --multipass icon.svg
      ```
      
      Order matters — SVGO's default plugins can inline or restructure attributes in
      ways that make the normalizer's paint rebinding harder to apply cleanly.
      
      Keep `viewBox` through every step. Dropping it is the one optimisation that
      breaks scaling outright, and some aggressive configs still do it.
      
  • scripts
    • normalize-icon.py 21.2 KB
      #!/usr/bin/env python3
      """Normalize an SVG icon or brand mark for inline, themeable, collision-free use.
      
      Strips editor cruft, namespaces internal ids so inlined SVGs cannot clobber each
      other, applies accessibility attributes, and applies exactly ONE deliberate
      colour treatment. Optionally emits a <symbol> for a sprite sheet.
      
      Colour is never guessed. A single-colour source rebinds to currentColor; a
      MULTI-COLOUR source (a brand mark) is refused unless you name what should happen
      to it, because silently flattening a mark is both lossy and a trademark
      modification.
      
      Usage:   normalize-icon.py [OPTIONS] <FILE|->
      Input:   an SVG file path, or - to read from stdin
      Output:  stdout - the normalized SVG (or a JSON envelope under --json)
      Stderr:  notes, warnings, errors
      Exit:    0 ok, 2 usage, 3 not-found, 4 validation (not parseable SVG),
               10 --check found changes that normalization would make,
               11 multi-colour source refused (pick a colour mode)
      
      Sanitises on the way through: <script>, <foreignObject>, on* event handlers and
      javascript:/vbscript:/data:text hrefs are removed, because an INLINED svg runs
      script in the host page's origin while an <img src="x.svg"> does not.
      
      Examples:
        normalize-icon.py icon.svg                        # mono icon -> currentColor
        normalize-icon.py --keep-colour logo.svg          # brand mark, colours untouched
        normalize-icon.py --greyscale logo.svg            # luminance-mapped grey variant
        normalize-icon.py --tint '#fff' logo.svg          # knockout / reverse-out
        normalize-icon.py --symbol --id i-search icon.svg >> sprite.svg
        normalize-icon.py --check src/icons/*.svg || echo needs-normalizing
      """
      
      import argparse
      import hashlib
      import json
      import os
      import re
      import sys
      import tempfile
      import xml.etree.ElementTree as ET
      
      SVG_NS = "http://www.w3.org/2000/svg"
      
      EXIT_OK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2
      EXIT_NOT_FOUND, EXIT_VALIDATION = 3, 4
      EXIT_CHANGES = 10
      EXIT_MULTICOLOUR = 11
      
      DROP_TAGS = {"metadata", "namedview", "foreignObject", "script"}
      CRUFT_NS_HINTS = ("sodipodi", "inkscape", "figma", "sketch", "adobe", "serif",
                        "krita", "vectornator", "affinity")
      PAINT_KEEP = {"none", "inherit", "currentcolor", "transparent"}
      PAINT_ATTRS = ("fill", "stroke", "stop-color", "flood-color", "lighting-color")
      GRADIENT_TAGS = {"linearGradient", "radialGradient", "pattern"}
      # Attributes whose value may be a url(#id) or #id reference needing namespacing.
      REF_ATTRS = ("fill", "stroke", "clip-path", "mask", "filter", "href", "xlink:href",
                   "marker-start", "marker-mid", "marker-end", "stop-color")
      
      
      def log(msg):
          print(msg, file=sys.stderr)
      
      
      def local(tag):
          return tag.rsplit("}", 1)[-1] if isinstance(tag, str) and "}" in tag else tag
      
      
      def is_literal_paint(value):
          if value is None:
              return False
          v = value.strip().lower()
          return bool(v) and v not in PAINT_KEEP and not v.startswith("url(")
      
      
      def canon_colour(value):
          """Normalise a colour string for counting distinct values."""
          v = value.strip().lower()
          m = re.fullmatch(r"#([0-9a-f]{3})", v)
          if m:
              return "#" + "".join(c * 2 for c in m.group(1))
          return v
      
      
      def to_greyscale(value):
          """Rec.709 luminance-mapped grey. Non-hex values pass through unchanged."""
          v = canon_colour(value)
          m = re.fullmatch(r"#([0-9a-f]{6})", v)
          if not m:
              return None
          r, g, b = (int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4))
          y = round(0.2126 * r + 0.7152 * g + 0.0722 * b)
          y = max(0, min(255, y))
          return "#%02x%02x%02x" % (y, y, y)
      
      
      def all_achromatic(colours, tolerance=16):
          """True when every colour is a grey/near-grey (R, G and B within tolerance).
      
          Discriminates a mono icon drawn with several near-black values from a real
          brand mark, which carries chroma. Used only to choose the wording of a
          refusal - never to decide the refusal itself.
          """
          for c in colours:
              m = re.fullmatch(r"#([0-9a-f]{6})", canon_colour(c))
              if not m:
                  return False
              ch = [int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)]
              if max(ch) - min(ch) > tolerance:
                  return False
          return True
      
      
      def iter_style_decls(style):
          for decl in style.split(";"):
              if ":" in decl:
                  prop, _, val = decl.partition(":")
                  yield prop.strip(), val.strip()
              elif decl.strip():
                  yield decl.strip(), None
      
      
      # === ANALYSIS (runs before any mutation, so refusals are decided on the source) ===
      
      def analyse(root):
          """Collect the facts the colour decision depends on."""
          colours, ids, refs = set(), set(), set()
          has_gradient = False
          for el in root.iter():
              if local(el.tag) in GRADIENT_TAGS:
                  has_gradient = True
              for name, value in el.attrib.items():
                  lname = local(name).lower()
                  if lname == "id":
                      ids.add(value)
                  if lname in PAINT_ATTRS and is_literal_paint(value):
                      colours.add(canon_colour(value))
                  if lname == "style":
                      for prop, val in iter_style_decls(value):
                          if prop.lower() in PAINT_ATTRS and val and is_literal_paint(val):
                              colours.add(canon_colour(val))
                  for m in re.finditer(r"url\(#([^)]+)\)", value or ""):
                      refs.add(m.group(1))
                  if lname in ("href", "xlink:href") and (value or "").startswith("#"):
                      refs.add(value[1:])
          return {"colours": colours, "ids": ids, "refs": refs, "has_gradient": has_gradient}
      
      
      # === MUTATION ===
      
      def paint_for(value, mode, tint):
          """Map one literal paint to its replacement under the chosen colour mode."""
          if mode == "keep":
              return None
          if mode == "grey":
              return to_greyscale(value)
          if mode == "tint":
              return tint
          return "currentColor"
      
      
      def clean_element(el, mode, tint, stats):
          doomed = [c for c in el if local(c.tag) in DROP_TAGS]
          for d in doomed:
              el.remove(d)
              stats["dropped_elements"] += 1
          for child in list(el):
              clean_element(child, mode, tint, stats)
      
          for name in list(el.attrib):
              lname = local(name).lower()
              # Active content. An inlined SVG runs script in the host page's origin -
              # an <img src="x.svg"> does not - so anything that normalizes a
              # third-party SVG *for inlining* is a sanitiser whether it meant to be
              # or not. <script>/<foreignObject> go via DROP_TAGS; handlers and
              # scheme-bearing hrefs have to be stripped here.
              if lname.startswith("on"):
                  del el.attrib[name]
                  stats["stripped_active"] += 1
                  continue
              if lname in ("href", "xlink:href"):
                  scheme = el.attrib[name].strip().lower().replace("\t", "").replace("\n", "")
                  if scheme.startswith(("javascript:", "vbscript:")) or scheme.startswith("data:text"):
                      del el.attrib[name]
                      stats["stripped_active"] += 1
                      continue
              if "}" in name and not name.startswith("{%s}" % SVG_NS):
                  if any(h in name.lower() for h in CRUFT_NS_HINTS):
                      del el.attrib[name]
                      stats["dropped_attrs"] += 1
                      continue
              if lname in PAINT_ATTRS and is_literal_paint(el.attrib[name]):
                  new = paint_for(el.attrib[name], mode, tint)
                  if new and new != el.attrib[name]:
                      el.attrib[name] = new
                      stats["recoloured"] += 1
              elif lname == "style":
                  out, changed = [], 0
                  for prop, val in iter_style_decls(el.attrib[name]):
                      if val is None:
                          out.append(prop)
                          continue
                      if prop.lower() in PAINT_ATTRS and is_literal_paint(val):
                          new = paint_for(val, mode, tint)
                          if new and new != val:
                              out.append("%s:%s" % (prop, new))
                              changed += 1
                              continue
                      out.append("%s:%s" % (prop, val))
                  stats["recoloured"] += changed
                  joined = ";".join(p for p in out if p)
                  if joined:
                      el.attrib[name] = joined
                  else:
                      del el.attrib[name]
      
      
      def namespace_ids(root, prefix, stats):
          """Prefix every internal id and the references pointing at it.
      
          Two SVGs inlined into one document that both declare id="a" collide: the
          LAST definition wins for the whole page, so the first icon silently renders
          with the wrong gradient/clip/mask. Brand marks hit this constantly because
          they carry gradients. Namespacing on the way in is the only fix that does
          not depend on whoever assembles the page.
          """
          mapping = {}
          for el in root.iter():
              for name in list(el.attrib):
                  if local(name).lower() == "id":
                      old = el.attrib[name]
                      if old and not old.startswith(prefix + "-"):
                          mapping[old] = "%s-%s" % (prefix, old)
          if not mapping:
              return
          for el in root.iter():
              for name in list(el.attrib):
                  lname = local(name).lower()
                  value = el.attrib[name]
                  if lname == "id" and value in mapping:
                      el.attrib[name] = mapping[value]
                      continue
                  if lname in REF_ATTRS or lname == "style":
                      def rep(m):
                          return "url(#%s)" % mapping.get(m.group(1), m.group(1))
                      new = re.sub(r"url\(#([^)]+)\)", rep, value)
                      if lname in ("href", "xlink:href") and value.startswith("#"):
                          new = "#" + mapping.get(value[1:], value[1:])
                      if new != value:
                          el.attrib[name] = new
          stats["namespaced_ids"] = len(mapping)
      
      
      def derive_viewbox(root):
          w, h = root.get("width"), root.get("height")
          if not (w and h):
              return None
          num = re.compile(r"^\s*([0-9.]+)\s*(px)?\s*$", re.I)
          mw, mh = num.match(w), num.match(h)
          return "0 0 %s %s" % (mw.group(1), mh.group(1)) if (mw and mh) else None
      
      
      def normalize(source_text, args, stats, mode, prefix):
          try:
              root = ET.fromstring(source_text)
          except ET.ParseError as exc:
              log("[FAIL] not parseable as XML: %s" % exc)
              return None, None
          if local(root.tag) != "svg":
              log("[FAIL] root element is <%s>, expected <svg>" % local(root.tag))
              return None, None
      
          facts = analyse(root)
          vb = root.get("viewBox") or derive_viewbox(root)
          if not vb:
              log("[FAIL] no viewBox and none derivable from width/height")
              return None, facts
          stats["viewBox"] = vb
          stats["source_colours"] = sorted(facts["colours"])
          stats["has_gradient"] = facts["has_gradient"]
      
          clean_element(root, mode, args.tint, stats)
          if not args.no_namespace:
              namespace_ids(root, prefix, stats)
      
          kept = {"viewBox": vb}
          for name, value in root.attrib.items():
              lname = local(name).lower()
              if lname in ("fill", "stroke", "stroke-width", "stroke-linecap",
                           "stroke-linejoin", "stroke-miterlimit", "fill-rule", "clip-rule"):
                  kept[lname] = value
              elif lname == "preserveaspectratio":
                  kept["preserveAspectRatio"] = value
          if args.keep_size:
              for dim in ("width", "height"):
                  val = root.get(dim)
                  if val:
                      kept[dim] = val
          else:
              stats["size_stripped"] = bool(root.get("width") or root.get("height"))
      
          if args.stroke:
              kept["fill"] = "none"
              kept.setdefault("stroke", "currentColor")
              kept.setdefault("stroke-width", "1.5")
              kept.setdefault("stroke-linecap", "round")
              kept.setdefault("stroke-linejoin", "round")
          elif mode == "current" and "fill" not in kept:
              kept["fill"] = "currentColor"
      
          root.attrib.clear()
          for k, v in kept.items():
              root.set(k, v)
      
          # Accessibility: decorative or named. Never neither.
          if args.title:
              root.set("role", "img")
              t = ET.Element("title")
              t.text = args.title
              root.insert(0, t)
              stats["a11y"] = "labelled"
          else:
              root.set("aria-hidden", "true")
              root.set("focusable", "false")
              stats["a11y"] = "decorative"
      
          if args.symbol:
              root.tag = "symbol"
              root.set("id", args.id or "icon")
              for drop in ("aria-hidden", "focusable"):
                  root.attrib.pop(drop, None)
              stats["form"] = "symbol"
          else:
              root.tag = "svg"
              root.set("xmlns", SVG_NS)
              stats["form"] = "svg"
              if args.id:
                  root.set("id", args.id)
      
          for el in root.iter():
              el.tag = local(el.tag)
              for name in list(el.attrib):
                  if "}" in name:
                      el.attrib[local(name)] = el.attrib.pop(name)
      
          return ET.tostring(root, encoding="unicode"), facts
      
      
      def main():
          ap = argparse.ArgumentParser(
              prog="normalize-icon.py", add_help=True,
              description="Normalize an SVG icon or brand mark for inline, themeable use.",
              epilog=("COLOUR MODES (choose at most one; multi-colour input requires one):\n"
                      "  default        single-colour source -> currentColor\n"
                      "  --keep-colour  leave every colour untouched (correct for a brand mark)\n"
                      "  --flatten      force a multi-colour source to currentColor (lossy)\n"
                      "  --greyscale    luminance-map each colour to grey\n"
                      "  --tint COLOUR  flatten every paint to COLOUR (--tint '#fff' = knockout)\n\n"
                      "EXAMPLES:\n"
                      "  normalize-icon.py icon.svg\n"
                      "  normalize-icon.py --keep-colour logo.svg -o src/logos/acme.svg\n"
                      "  normalize-icon.py --greyscale logo.svg > acme-grey.svg\n"
                      "  normalize-icon.py --tint '#fff' logo.svg > acme-knockout.svg\n"
                      "  normalize-icon.py --symbol --id i-search icon.svg >> sprite.svg\n"
                      "  normalize-icon.py --check vendor.svg || echo needs-normalizing\n"),
              formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument("file", help="SVG file path, or - for stdin")
          ap.add_argument("--stroke", action="store_true", help="stroke icon: fill=none, stroke=currentColor")
          ap.add_argument("--title", help="accessible name; omit for a decorative icon")
          ap.add_argument("--id", help="id for the output element (required by --symbol)")
          ap.add_argument("--symbol", action="store_true", help="emit a <symbol> for a sprite sheet")
          ap.add_argument("--keep-size", action="store_true", help="keep width/height instead of CSS sizing")
          ap.add_argument("--keep-colour", "--keep-color", dest="keep_colour", action="store_true",
                          help="leave all colours untouched (brand marks)")
          ap.add_argument("--flatten", action="store_true",
                          help="force a multi-colour source to currentColor (lossy)")
          ap.add_argument("--greyscale", "--grayscale", dest="greyscale", action="store_true",
                          help="luminance-map every colour to grey")
          ap.add_argument("--tint", metavar="COLOUR",
                          help="flatten every paint to COLOUR (use '#fff' for a knockout)")
          ap.add_argument("--id-prefix", help="prefix for internal ids (default: derived from --id/filename)")
          ap.add_argument("--no-namespace", action="store_true",
                          help="do not prefix internal ids (risks collisions when inlining)")
          ap.add_argument("--check", action="store_true", help="report only; exit 10 if it would change")
          ap.add_argument("--json", action="store_true", help="emit a JSON envelope")
          ap.add_argument("-o", "--output", help="write to this path atomically")
          args = ap.parse_args()
      
          chosen = [n for n, v in (("--keep-colour", args.keep_colour), ("--flatten", args.flatten),
                                   ("--greyscale", args.greyscale), ("--tint", bool(args.tint))) if v]
          if len(chosen) > 1:
              log("[FAIL] colour modes are mutually exclusive; got %s" % ", ".join(chosen))
              return EXIT_USAGE
          if args.symbol and not args.id:
              log("[FAIL] --symbol requires --id")
              return EXIT_USAGE
          if args.check and args.output:
              log("[FAIL] --check is report-only and cannot be combined with -o")
              return EXIT_USAGE
          if args.tint is not None and not args.tint.strip():
              log("[FAIL] --tint needs a colour value")
              return EXIT_USAGE
      
          mode = ("keep" if args.keep_colour else "grey" if args.greyscale
                  else "tint" if args.tint else "current")
      
          if args.file == "-":
              source, origin = sys.stdin.read(), "<stdin>"
          else:
              path = os.path.realpath(args.file)
              if not os.path.isfile(path):
                  log("[FAIL] no such file: %s" % args.file)
                  return EXIT_NOT_FOUND
              with open(path, "r", encoding="utf-8", errors="replace") as fh:
                  source = fh.read()
              origin = path
      
          # id prefix must be deterministic: same input + same flags => same output,
          # so a --check in CI never disagrees with the write that follows it.
          prefix = (args.id_prefix or args.id
                    or (os.path.splitext(os.path.basename(origin))[0] if origin != "<stdin>" else None)
                    or "i" + hashlib.sha1(source.encode("utf-8")).hexdigest()[:6])
          prefix = re.sub(r"[^A-Za-z0-9_-]", "-", prefix).strip("-") or "icon"
      
          stats = {"dropped_elements": 0, "dropped_attrs": 0, "recoloured": 0,
                   "stripped_active": 0, "namespaced_ids": 0, "size_stripped": False, "viewBox": None,
                   "a11y": None, "form": None, "source_colours": [], "has_gradient": False,
                   "colour_mode": mode}
      
          result, facts = normalize(source, args, stats, mode, prefix)
          if result is None or facts is None:
              if args.json:
                  print(json.dumps({"error": {"code": "VALIDATION",
                                              "message": "input is not a usable SVG icon",
                                              "details": {"source": origin}}}))
              return EXIT_VALIDATION
      
          # The refusal: a multi-colour source has no safe default. Flattening a brand
          # mark to one colour is lossy AND a trademark modification, so the caller
          # must say which they want rather than discovering it after the fact.
          multi = len(facts["colours"]) > 1 or facts["has_gradient"]
          if multi and mode == "current" and not args.flatten:
              detail = "%d distinct colours%s" % (len(facts["colours"]),
                                                  " + gradient/pattern" if facts["has_gradient"] else "")
              if args.json:
                  print(json.dumps({"error": {"code": "MULTICOLOUR",
                                              "message": "multi-colour source needs an explicit colour mode",
                                              "details": {"source": origin, "colours": sorted(facts["colours"]),
                                                          "has_gradient": facts["has_gradient"]}}}))
              elif all_achromatic(facts["colours"]) and not facts["has_gradient"]:
                  # Several near-black/grey values is a sloppily-drawn MONO icon, not a
                  # mark. Still refuse rather than guess - losing a two-tone grey is a
                  # real loss - but lead with the option the caller almost certainly wants.
                  log("[FAIL] %s has %s, all achromatic - most likely a mono icon drawn"
                      % (origin, detail))
                  log("       with several greys rather than a brand mark.")
                  log("         --flatten       collapse them to currentColor (probably this)")
                  log("         --keep-colour   keep the grey steps exactly as drawn")
              else:
                  log("[FAIL] %s looks like a brand mark (%s)." % (origin, detail))
                  log("       Rebinding it to currentColor would flatten it to a silhouette,")
                  log("       which is lossy and counts as modifying the mark. Choose one:")
                  log("         --keep-colour   keep it exactly as published (usually correct)")
                  log("         --greyscale     luminance-mapped grey variant")
                  log("         --tint '#fff'   knockout / reverse-out variant")
                  log("         --flatten       yes, really flatten it to currentColor")
              return EXIT_MULTICOLOUR
      
          changed = bool(stats["dropped_elements"] or stats["dropped_attrs"]
                         or stats["recoloured"] or stats["size_stripped"]
                         or stats["namespaced_ids"] or stats["stripped_active"])
      
          if args.json:
              print(json.dumps({
                  "data": [dict({"source": origin, "svg": None if args.check else result,
                                 "changed": changed}, **stats)],
                  "meta": {"count": 1, "changed": changed,
                           "schema": "claude-mods.icon-ops.normalize-icon/v1"}}))
          elif not args.check:
              if args.output:
                  d = os.path.dirname(os.path.realpath(args.output)) or "."
                  fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
                  with os.fdopen(fd, "w", encoding="utf-8") as fh:
                      fh.write(result)
                  os.replace(tmp, args.output)
                  log("[PASS] wrote %s" % args.output)
              else:
                  print(result)
          else:
              log("[%s] %s: %s" % ("WARN" if changed else "PASS", origin,
                                   "normalization would change this file" if changed
                                   else "already normalized"))
      
          return EXIT_CHANGES if (args.check and changed) else EXIT_OK
      
      
      if __name__ == "__main__":
          try:
              sys.exit(main())
          except KeyboardInterrupt:
              sys.exit(EXIT_ERROR)
      
  • tests
    • run.sh 14 KB
      #!/usr/bin/env bash
      # Self-test for icon-ops — normalize-icon.py behaviour plus a structural check
      # of the shipped sprite asset.
      #
      # Fully offline and self-contained: fixtures are written to a temp dir, nothing
      # is fetched. Needs python3 (or python); skips cleanly with exit 0 where absent,
      # like the windows-ops / mac-ops suites gate on their platform.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass (or skipped), 1 a failure
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      NORM="$SKILL/scripts/normalize-icon.py"
      SPRITE="$SKILL/assets/sprite-template.svg"
      
      PASS=0; FAIL=0
      ok(){ PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no(){ FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      
      echo "=== icon-ops self-test ==="
      
      # ── static: resources exist and are cited ────────────────────────────────────
      echo "-- resources --"
      [ -f "$NORM" ]   && ok "normalize-icon.py present"   || no "normalize-icon.py missing"
      [ -f "$SPRITE" ] && ok "sprite-template.svg present" || no "sprite-template.svg missing"
      for r in references/icon-sources.md references/inline-delivery.md \
               references/brand-variants.md references/favicons-and-app-icons.md; do
        [ -f "$SKILL/$r" ] && ok "$r present" || no "$r missing"
        # An uncited resource is dead weight the router never finds (resource protocol §1).
        grep -q "$(basename "$r")" "$SKILL/SKILL.md" && ok "$r cited from SKILL.md" || no "$r not cited from SKILL.md"
      done
      grep -q 'normalize-icon.py' "$SKILL/SKILL.md" && ok "script cited from SKILL.md" || no "script not cited from SKILL.md"
      grep -q 'sprite-template.svg' "$SKILL/SKILL.md" && ok "asset cited from SKILL.md" || no "asset not cited from SKILL.md"
      
      # ── dynamic: needs python; skip cleanly where absent ─────────────────────────
      # Probe by EXECUTING python, not with `command -v`: on Windows the python3 name
      # resolves to a Microsoft Store app-execution-alias stub that is present on PATH
      # and exits 49 with an install advert instead of running anything.
      PY=""
      for c in python3 python py; do
        if "$c" -c 'import sys' >/dev/null 2>&1; then PY="$c"; break; fi
      done
      if [ -z "$PY" ]; then
        echo "  (python not found — skipping dynamic checks)"
        echo "=== $PASS passed, $FAIL failed ==="
        [ "$FAIL" -eq 0 ] || exit 1
        exit 0
      fi
      
      echo "-- normalize-icon --"
      "$PY" -m py_compile "$NORM" 2>/dev/null && ok "py_compile clean" || no "py_compile failed"
      "$PY" "$NORM" --help >/dev/null 2>&1 && ok "--help exits 0" || no "--help nonzero"
      "$PY" "$NORM" --help 2>/dev/null | grep -q 'EXAMPLES' && ok "--help lists EXAMPLES" || no "--help has no EXAMPLES"
      "$PY" "$NORM" --help 2>/dev/null | grep -q 'COLOUR MODES' && ok "--help documents colour modes" || no "--help omits colour modes"
      
      TMP="$(mktemp -d)"
      trap 'rm -rf "$TMP"' EXIT
      
      # A messy but genuinely MONO vendor export: editor namespaces, metadata, one
      # colour expressed two ways, fixed width/height.
      cat > "$TMP/mono.svg" <<'FIXTURE'
      <?xml version="1.0" encoding="UTF-8"?>
      <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="24" height="24" viewBox="0 0 24 24" inkscape:version="1.1">
        <metadata id="m7">junk</metadata>
        <circle cx="11" cy="11" r="7" fill="#000000"/>
        <path d="M21 21l-4.3-4.3" style="fill:#000000;stroke-width:2"/>
      </svg>
      FIXTURE
      
      # A three-colour brand mark. Flattening this is the bug the guard exists to stop.
      cat > "$TMP/mark.svg" <<'FIXTURE'
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
        <path d="M12 2 L22 12 L12 22 Z" fill="#4285F4"/>
        <path d="M2 12 L12 2 L12 22 Z" fill="#EA4335"/>
        <circle cx="12" cy="12" r="3" fill="#FBBC05"/>
      </svg>
      FIXTURE
      
      # A gradient mark: the id-collision case. Two of these inlined into one page
      # both declaring id="a" means the LAST wins document-wide and the first renders
      # with the wrong gradient.
      cat > "$TMP/grad.svg" <<'FIXTURE'
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
        <defs><linearGradient id="a"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
        <circle cx="12" cy="12" r="10" fill="url(#a)"/>
      </svg>
      FIXTURE
      
      out="$("$PY" "$NORM" "$TMP/mono.svg" 2>/dev/null)"; rc=$?
      [ "$rc" = "0" ] && ok "mono source normalizes (exit 0)" || no "mono normalize exited $rc"
      printf '%s' "$out" | grep -qE '#[0-9a-fA-F]{3,6}' && no "literal hex survived on a mono icon" \
                                                        || ok "no literal hex remains"
      printf '%s' "$out" | grep -q 'currentColor' && ok "paints rebound to currentColor" || no "no currentColor in output"
      printf '%s' "$out" | grep -q 'metadata'     && no "metadata element survived"      || ok "metadata dropped"
      printf '%s' "$out" | grep -q 'inkscape'     && no "editor namespace survived"      || ok "editor cruft dropped"
      printf '%s' "$out" | grep -qE 'width="24"'  && no "fixed width survived"           || ok "fixed width/height stripped"
      printf '%s' "$out" | grep -q 'viewBox'      && ok "viewBox preserved"              || no "viewBox lost (breaks scaling)"
      printf '%s' "$out" | grep -q 'aria-hidden'  && ok "decorative a11y applied"        || no "no a11y attributes applied"
      
      echo "-- multi-colour guard --"
      # THE regression this guard exists for: a brand mark must never be silently
      # flattened to a silhouette. That is lossy AND a trademark modification.
      "$PY" "$NORM" "$TMP/mark.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "11" ] && ok "multi-colour mark refused -> exit 11" || no "multi-colour mark -> exit $rc, expected 11"
      err="$("$PY" "$NORM" "$TMP/mark.svg" 2>&1 1>/dev/null)"
      case "$err" in *--keep-colour*) ok "refusal names the safe option";; *) no "refusal does not name --keep-colour";; esac
      # A gradient alone is enough to disqualify the mono default.
      "$PY" "$NORM" "$TMP/grad.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "11" ] && ok "gradient source refused -> exit 11" || no "gradient source -> exit $rc, expected 11"
      # Achromatic multi-value sources get different wording (mono icon, not a mark).
      printf '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="#1a1a1a"/><path d="M6 6h12v12H6z" fill="#333333"/></svg>' > "$TMP/greys.svg"
      err="$("$PY" "$NORM" "$TMP/greys.svg" 2>&1 1>/dev/null)"
      case "$err" in *achromatic*) ok "all-grey source gets mono-icon wording";; *) no "all-grey source misreported as a brand mark";; esac
      
      echo "-- colour modes --"
      kc="$("$PY" "$NORM" --keep-colour "$TMP/mark.svg" 2>/dev/null)"; rc=$?
      [ "$rc" = "0" ] && ok "--keep-colour exits 0" || no "--keep-colour exited $rc"
      for c in 4285F4 EA4335 FBBC05; do
        printf '%s' "$kc" | grep -qi "$c" && ok "--keep-colour preserves #$c" || no "--keep-colour lost #$c"
      done
      printf '%s' "$kc" | grep -q 'currentColor' && no "--keep-colour injected currentColor" || ok "--keep-colour adds no currentColor"
      
      fl="$("$PY" "$NORM" --flatten "$TMP/mark.svg" 2>/dev/null)"; rc=$?
      [ "$rc" = "0" ] && ok "--flatten exits 0 (explicit opt-in)" || no "--flatten exited $rc"
      printf '%s' "$fl" | grep -q 'currentColor' && ok "--flatten collapses to currentColor" || no "--flatten did not flatten"
      
      # Rec.709 luminance, not an RGB average: #4285F4 -> 0.2126*66+0.7152*133+0.0722*244
      # = 127 = #7f7f7f. An average would give #939393, collapsing tonal separation.
      gs="$("$PY" "$NORM" --greyscale "$TMP/mark.svg" 2>/dev/null)"
      printf '%s' "$gs" | grep -q '#7f7f7f' && ok "--greyscale uses Rec.709 luminance" || no "--greyscale luminance wrong"
      printf '%s' "$gs" | grep -qiE '#(4285F4|EA4335|FBBC05)' && no "--greyscale left a source colour" || ok "--greyscale replaced every colour"
      
      ko="$("$PY" "$NORM" --tint '#fff' "$TMP/mark.svg" 2>/dev/null)"
      printf '%s' "$ko" | grep -q 'fill="#fff"' && ok "--tint produces a knockout" || no "--tint did not apply the colour"
      
      "$PY" "$NORM" --greyscale --tint '#fff' "$TMP/mark.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "2" ] && ok "colour modes are mutually exclusive -> exit 2" || no "conflicting modes -> exit $rc, expected 2"
      "$PY" "$NORM" --tint '' "$TMP/mark.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "2" ] && ok "empty --tint -> exit 2" || no "empty --tint -> exit $rc, expected 2"
      
      echo "-- id namespacing --"
      # Without this, two inlined gradient marks collide on id="a".
      ns="$("$PY" "$NORM" --keep-colour "$TMP/grad.svg" 2>/dev/null)"
      printf '%s' "$ns" | grep -q 'id="grad-a"'      && ok "internal id is namespaced"        || no "internal id not namespaced"
      printf '%s' "$ns" | grep -q 'url(#grad-a)'     && ok "url(#id) reference rewritten"     || no "url(#id) reference not rewritten"
      printf '%s' "$ns" | grep -q 'id="a"'           && no "bare id=\"a\" still present"      || ok "no un-namespaced id remains"
      nsx="$("$PY" "$NORM" --keep-colour --no-namespace "$TMP/grad.svg" 2>/dev/null)"
      printf '%s' "$nsx" | grep -q 'id="a"' && ok "--no-namespace opts out" || no "--no-namespace did not opt out"
      # Determinism matters: a --check in CI must agree with the write that follows it.
      a="$("$PY" "$NORM" --keep-colour "$TMP/grad.svg" 2>/dev/null)"
      b="$("$PY" "$NORM" --keep-colour "$TMP/grad.svg" 2>/dev/null)"
      [ "$a" = "$b" ] && ok "output is deterministic across runs" || no "output is non-deterministic"
      
      echo "-- forms and a11y --"
      sout="$("$PY" "$NORM" --stroke "$TMP/mono.svg" 2>/dev/null)"
      printf '%s' "$sout" | grep -q 'fill="none"' && ok "--stroke forces fill=none" || no "--stroke did not force fill=none"
      tout="$("$PY" "$NORM" --title 'Search' "$TMP/mono.svg" 2>/dev/null)"
      printf '%s' "$tout" | grep -q '<title>Search</title>' && ok "--title injects <title>" || no "--title did not inject <title>"
      printf '%s' "$tout" | grep -q 'role="img"'            && ok "--title sets role=img"   || no "--title did not set role=img"
      yout="$("$PY" "$NORM" --symbol --id i-x "$TMP/mono.svg" 2>/dev/null)"
      printf '%s' "$yout" | grep -q '<symbol'     && ok "--symbol emits a <symbol>"     || no "--symbol did not emit <symbol>"
      printf '%s' "$yout" | grep -q 'id="i-x"'    && ok "--symbol carries the given id" || no "--symbol lost the id"
      printf '%s' "$yout" | grep -q 'aria-hidden' && no "symbol carries a11y attrs (they belong on the consuming <svg>)" \
                                                  || ok "symbol leaves a11y to the consumer"
      
      echo "-- envelope and guard rails --"
      jout="$("$PY" "$NORM" --json "$TMP/mono.svg" 2>/dev/null)"
      printf '%s' "$jout" | grep -q 'claude-mods.icon-ops.normalize-icon/v1' \
        && ok "JSON envelope declares the schema" || no "JSON envelope schema missing"
      printf '%s' "$jout" | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert "data" in d and "meta" in d' 2>/dev/null \
        && ok "JSON envelope parses with data+meta" || no "JSON envelope malformed"
      # A refusal must still be machine-readable under --json.
      jerr="$("$PY" "$NORM" --json "$TMP/mark.svg" 2>/dev/null)"
      printf '%s' "$jerr" | grep -q 'MULTICOLOUR' && ok "refusal is structured under --json" || no "refusal not structured under --json"
      
      "$PY" "$NORM" --check "$TMP/mono.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "10" ] && ok "--check on dirty -> exit 10" || no "--check dirty -> exit $rc, expected 10"
      printf '%s' "$out" > "$TMP/clean.svg"
      "$PY" "$NORM" --check "$TMP/clean.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "0" ] && ok "--check on normalized -> exit 0 (idempotent)" || no "--check clean -> exit $rc, expected 0"
      
      "$PY" "$NORM" --symbol "$TMP/mono.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "2" ] && ok "--symbol without --id -> exit 2" || no "--symbol without --id -> exit $rc, expected 2"
      "$PY" "$NORM" "$TMP/__absent__.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "3" ] && ok "missing file -> exit 3" || no "missing file -> exit $rc, expected 3"
      printf '<html><body>no</body></html>' > "$TMP/bad.svg"
      "$PY" "$NORM" "$TMP/bad.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "4" ] && ok "non-SVG input -> exit 4" || no "non-SVG -> exit $rc, expected 4"
      "$PY" "$NORM" --check "$TMP/mono.svg" -o "$TMP/x.svg" >/dev/null 2>&1; rc=$?
      [ "$rc" = "2" ] && ok "--check with -o -> exit 2" || no "--check with -o -> exit $rc, expected 2"
      
      echo "-- sanitisation --"
      # An INLINED svg runs script in the host page's origin; an <img src> does not.
      # This skill tells people to inline third-party SVGs, which makes the normalizer
      # a sanitiser whether it set out to be one or not.
      cat > "$TMP/hostile.svg" <<'FIXTURE'
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" onload="alert(1)">
        <script>alert(2)</script>
        <a href="javascript:alert(3)"><circle cx="12" cy="12" r="8" fill="#000" onclick="alert(4)"/></a>
      </svg>
      FIXTURE
      hs="$("$PY" "$NORM" "$TMP/hostile.svg" 2>/dev/null)"
      printf '%s' "$hs" | grep -qi 'onload'      && no "onload survived"            || ok "onload stripped"
      printf '%s' "$hs" | grep -qi 'onclick'     && no "onclick survived"           || ok "onclick stripped"
      printf '%s' "$hs" | grep -qi 'javascript:' && no "javascript: href survived"  || ok "javascript: href stripped"
      printf '%s' "$hs" | grep -qi '<script'     && no "<script> survived"          || ok "<script> dropped"
      printf '%s' "$hs" | grep -qi 'alert'       && no "script payload survived"    || ok "no payload remains"
      hj="$("$PY" "$NORM" --json "$TMP/hostile.svg" 2>/dev/null)"
      printf '%s' "$hj" | grep -q '"stripped_active": 3' && ok "sanitiser count reported in --json"                                                    || no "stripped_active count wrong or absent"
      
      echo "-- shipped sprite asset --"
      # Structural (offline) verification of the template we tell people to copy: it
      # must parse, and every <symbol> must keep its OWN viewBox or mixed-grid icons
      # crop instead of scaling.
      "$PY" - "$SPRITE" <<'CHECK' && ok "sprite template parses and every symbol has a viewBox" || no "sprite template invalid or a symbol lacks viewBox"
      import sys, xml.etree.ElementTree as ET
      root = ET.parse(sys.argv[1]).getroot()
      syms = [e for e in root.iter() if e.tag.rsplit('}', 1)[-1] == 'symbol']
      assert syms, 'no <symbol> elements in template'
      assert all(s.get('viewBox') for s in syms), 'a symbol is missing viewBox'
      assert all(s.get('id') for s in syms), 'a symbol is missing id'
      CHECK
      grep -q 'currentColor' "$SPRITE" && ok "sprite template defaults to currentColor" || no "sprite template hardcodes colour"
      
      echo "=== $PASS passed, $FAIL failed ==="
      [ "$FAIL" -eq 0 ] || exit 1
      
  • SKILL.md 15.1 KB
    ---
    name: icon-ops
    description: "Source, vet, normalize and ship SVG icons for web UI - set selection, licence and trademark traps, currentColor theming, sprite/inline delivery, and accessibility. Triggers on: icon, icons, svg icon, find an icon, add an icon, icon set, icon library, iconify, lucide, heroicons, phosphor, tabler, feather, material symbols, font awesome, simple icons, brand logo, icon sprite, svg sprite, symbol use, currentColor, icon won't change colour, icon font, icon accessibility, aria-hidden icon, icon-only button, icon size, icons look inconsistent, mixed icon sets, normalize svg, strip svg cruft, optimise svg, brandfetch, company logo, client logo, logo by domain, brand assets api, logo api, thesvg, brand icon, brandmark, brand mark, find a logo, logo wall, partner logo, greyscale logo, grayscale, tint a logo, reverse out, knockout, mono logo, dark mode logo, favicon, app icon, apple-touch-icon, maskable icon, svg id collision."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: svg-brand-tint-ops, color-ops, tailwind-ops
    ---
    
    # icon-ops
    
    Getting an icon onto a page is easy. Getting one that themes correctly, carries
    the right licence, matches the twelve icons beside it, and behaves for a screen
    reader is where the work actually is.
    
    ## Helps with
    
    An icon that won't change colour on hover, in dark mode, or when the theme
    switches — almost always a hardcoded `#000` in the file where `currentColor`
    should be.
    
    A UI where the icons "look off" without an obvious cause. Usually two icon sets
    mixed: different grid size, different stroke width, different corner language.
    Individually fine, together visibly wrong.
    
    Choosing an icon set at the start of a project, when the choice is cheap, rather
    than after 60 icons are embedded.
    
    Using a brand logo — GitHub, Google, a client's mark — and needing to know
    whether you actually may. The file licence does not answer this; trademark does.
    
    Needing a logo for an arbitrary company that no icon set carries. That is a
    different category from icon sets — a runtime lookup by domain, not a committed
    glyph — with its own quota, caching and trademark consequences.
    
    Icon-only buttons that a screen reader announces as "button", or announces
    twice. Both come from putting the accessible name in the wrong place.
    
    Vendor SVGs carrying Inkscape/Figma metadata, fixed `width`/`height` that fights
    CSS, and inline styles that resist theming.
    
    Deciding between inline SVG, a `<symbol>` sprite, framework components, and an
    icon font — and discovering too late that `<img src="icon.svg">` cannot be
    recoloured at all.
    
    A sprite that renders nothing in production but worked locally (the external
    `<use>` CORS trap).
    
    Two inlined logos where the second one's gradient bleeds into the first. Both
    files declared `id="a"`; the last definition in the document wins for the whole
    page. Brand marks hit this constantly because they carry gradients.
    
    Needing a mark in grey, knocked out of a dark header, or in one brand ink — and
    wanting to know whether to generate it or use the owner's published variant.
    
    A logo wall where one wide wordmark dominates because everything was set to the
    same `width`.
    
    Favicons and app icons — the modern four-file set, and why an Android maskable
    icon gets its edges cropped.
    
    ## The core technique
    
    **`fill="currentColor"` is the whole game.** An icon that inherits the CSS
    `color` of its context gets hover, focus, disabled, dark mode, and every future
    theme for free, with no icon-specific CSS. An icon with a baked-in hex breaks all
    of them simultaneously, and each one gets "fixed" separately later.
    
    Everything else in this skill exists to get icons into that state and keep them
    there.
    
    ## Workflow
    
    ### 1. Choose the set before sourcing anything
    
    Lock four decisions; they constrain every icon that follows:
    
    | Decision | Options |
    |---|---|
    | Grid | 24 (most common) · 20 · 16 |
    | Family | stroke · filled · both-as-matched-pair |
    | Stroke width | 1.5 · 2 — **must be identical across the set** |
    | Corner language | round caps/joins · square |
    
    Sensible defaults: **Lucide** (ISC, 24-grid, stroke 2) for a general UI,
    **Heroicons** (MIT) when you want matched outline/solid/mini tiers, **Phosphor**
    (MIT) when you need multiple weights in one family.
    
    Reach for a second set only when the first genuinely lacks the concept — then
    match grid and stroke width and expect to redraw. Prefer a near-neighbour
    concept from your set over an exact match from a foreign one.
    
    Full comparison table, licences, and the aggregator problem →
    [`references/icon-sources.md`](references/icon-sources.md).
    
    ### 2. Vet the licence — two traps
    
    **Brand marks are trademarks regardless of file licence.** Simple Icons ships
    brand logos under CC0, but the marks remain their owners' property. Nominative
    use ("Sign in with GitHub") is fine; implying endorsement, recolouring a mark to
    your palette, or putting it in your own logo is not. Quoting "it's CC0" as
    clearance is the wrong answer.
    
    **A company logo is not a UI icon.** Three sources cover brand marks and they
    trade off reach against commitment — **Simple Icons** (committed, monochrome,
    themeable), **theSVG** (MIT, 6,500+ marks in brand colour via
    `npm i thesvg`, an MCP server needing no key, or `npx skills add glincker/thesvg`), and **Brandfetch** (runtime lookup by *domain*, any company, nothing
    committed; free key at developers.brandfetch.com/dashboard, hotlink-only URLs
    that expire in ~24h, and two free tiers that differ by 10,000x). All three carry
    the identical trademark position — see
    [Brand marks](references/icon-sources.md#brand-marks--three-sources-one-trademark-position).
    They resolve the mark for you; none of them clears it.
    
    **Aggregators hide the licence.** Iconify, and any icon-search MCP or plugin,
    resolve across 150+ sets each keeping its own terms. Record the *originating
    set* and its licence when the icon enters the repo — one line in `LICENSES.md`
    or atop the sprite. That single line is the difference between an answerable
    question and an audit.
    
    ### 3. Normalize before it enters the repo
    
    Vendor output is not shippable. `scripts/normalize-icon.py` strips editor cruft,
    **namespaces internal ids** so two inlined SVGs cannot clobber each other's
    gradients, drops fixed `width`/`height` so CSS controls size, and applies the
    correct accessibility attributes.
    
    **Colour is never guessed.** A single-colour source rebinds to `currentColor`. A
    multi-colour source is **refused (exit 11)** until you name the treatment,
    because flattening a mark to a silhouette is lossy *and* counts as modifying it:
    
    | Flag | Result |
    |---|---|
    | *(default)* | mono source → `currentColor` |
    | `--keep-colour` | colours untouched — the right default for someone else's mark |
    | `--greyscale` | Rec.709 luminance-mapped grey |
    | `--tint '#fff'` | flatten to one colour; white = knockout / reverse-out |
    | `--flatten` | yes, really collapse a multi-colour source to `currentColor` |
    
    **It also sanitises.** An *inlined* SVG runs script in your page's origin; an
    `<img src="x.svg">` does not. Since this skill tells you to inline third-party
    SVGs, the normalizer strips `<script>`, `<foreignObject>`, every `on*` handler
    and `javascript:`/`data:text` hrefs. Treat any SVG you did not author as
    untrusted input, and never inline one that has not been through this.
    
    ```bash
    # Would this file change? exit 10 = yes, 0 = already clean
    scripts/normalize-icon.py --check vendor.svg
    
    # Normalize a filled icon into the repo (atomic write)
    scripts/normalize-icon.py vendor.svg -o src/icons/search.svg
    
    # A brand mark: keep its colours, just clean and namespace it
    scripts/normalize-icon.py --keep-colour acme.svg -o src/logos/acme.svg
    
    # Stroke icon: forces fill=none, stroke=currentColor, consistent caps/joins
    scripts/normalize-icon.py --stroke vendor.svg -o src/icons/search.svg
    
    # Append to a sprite as a <symbol>
    scripts/normalize-icon.py --symbol --id i-search vendor.svg >> src/sprite.svg
    
    # Machine-readable result (what changed, and why)
    scripts/normalize-icon.py --json vendor.svg | jq '.data[0]'
    ```
    
    Exit codes: `0` ok · `2` usage · `3` no such file · `4` not a usable SVG ·
    `10` (`--check` only) normalization would change the file · `11` multi-colour
    source refused. The `--check` mode is a CI gate — run it over `src/icons/` to
    keep un-normalized icons out.
    
    For byte-level path optimisation, run **SVGO after** normalizing, never before:
    
    ```bash
    scripts/normalize-icon.py raw.svg -o icon.svg && npx svgo --multipass icon.svg
    ```
    
    ### 4. Deliver
    
    | Mechanism | Themeable | Use when |
    |---|---|---|
    | **`<symbol>` sprite + `<use>`** | Yes | **Default for a real UI** — many icons, reused |
    | Inline `<svg>` | Yes | Few icons, or per-path styling/animation |
    | Framework component | Yes | Component stack already in play; tree-shakes |
    | `<img src="icon.svg">` | **No** | Never for UI icons |
    | Icon font | Colour only | Legacy only — migrate, don't extend |
    
    Start a sprite from [`assets/sprite-template.svg`](assets/sprite-template.svg),
    which carries the hiding pattern that survives Safari, per-symbol `viewBox` so
    mixed grids scale correctly, and the sizing rule.
    
    **Size in `em`, never `px`:**
    
    ```css
    .icon { width: 1em; height: 1em; flex: none; }
    ```
    
    `1em` keeps the icon optically matched to its label at every type scale.
    `flex: none` stops a flex parent squashing it into an ellipse — the most common
    icon layout bug there is.
    
    **The trap that only shows in production:** an external
    `<use href="/sprite.svg#id">` is CORS-blocked cross-origin and renders nothing,
    sometimes with no console error. Inline the sprite into the document.
    
    Decision detail, icon-font failure modes, and optimisation order →
    [`references/inline-delivery.md`](references/inline-delivery.md).
    
    ### 5. Get the accessibility right — exactly two cases
    
    Every icon is decorative or meaningful. Leaving it undecided is the defect.
    
    ```html
    <!-- Decorative: text beside it already names the control -->
    <button>
      <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
      Delete
    </button>
    
    <!-- Meaningful: the icon IS the label -->
    <button aria-label="Delete item">
      <svg class="icon" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
    </button>
    ```
    
    **Name the control, not the icon.** The counter-intuitive part is that the SVG
    stays `aria-hidden` in *both* cases — a name on the icon *and* on the button
    produces a double announcement. `role="img"` + `<title>` is for standalone
    graphics, not for the contents of a control.
    
    Also: icon-only controls need a **24×24 CSS px** minimum interactive area (WCAG
    2.2 §2.5.8) — pad the control, don't grow the glyph. Never let colour alone carry
    meaning: pair it with a distinct shape.
    
    ### 6. Variants and site icons
    
    A mark rarely ships in one treatment. **Use the owner's published mono/reversed/
    greyscale asset when one exists** — theirs is drawn, yours is computed, and a
    designer already fixed the hairline that vanishes when knocked out. Generate
    only when they publish none.
    
    ```bash
    scripts/normalize-icon.py --tint '#fff'  acme.svg -o src/logos/acme-knockout.svg
    scripts/normalize-icon.py --greyscale    acme.svg -o src/logos/acme-grey.svg
    ```
    
    `filter: grayscale(1)` is right for a *hover-reveal effect* and wrong for a
    canonical asset. `filter: invert(1)` is **never** a knockout — it inverts hue
    too, so a blue mark comes back orange.
    
    **Logo walls: constrain both axes.** `width: 120px` on everything makes a wide
    wordmark occupy ~3x the visual area of a square badge. Use `max-width` **and**
    `max-height` in a fixed box, then correct optically by eye.
    
    **Favicons are a different mark**, not your logo scaled down — four files
    (`favicon.ico`, `icon.svg`, `apple-touch-icon.png` 180x180, and a *separate*
    512x512 maskable PNG whose content sits inside the centre 80%-diameter circle).
    
    Variant production, light/dark pairs, logo-wall sizing and logo `alt` conventions
    → [`references/brand-variants.md`](references/brand-variants.md). The favicon set,
    the theme-aware SVG favicon, and maskable safe zones →
    [`references/favicons-and-app-icons.md`](references/favicons-and-app-icons.md).
    
    ## What this skill doesn't cover
    
    - **Duotone/tri-tone treatments, filter-based tinting of a whole set, and
      raster→vector tracing** → `svg-brand-tint-ops`. This skill produces flat
      variants (mono, grey, knockout) of a single mark; that one does tonal
      re-mapping and vectorising.
    - **Choosing the palette itself** → `color-ops`
    - **Illustration and generative artwork** → `genart-ops`, `isometric-ops`
    - **Authoring new icons** — this skill sources, vets and ships existing ones
    
    ## Cross-references
    
    | When | Use |
    |---|---|
    | The icons are right but the palette isn't | `color-ops` |
    | A whole set needs brand recolouring or a logo needs vectorising | `svg-brand-tint-ops` |
    | Building the surrounding component styles | `tailwind-ops` |
    
    ## References
    
    - [`references/icon-sources.md`](references/icon-sources.md) — the set comparison
      table (licence, grid, family, notes) for the eleven sets worth knowing; the
      trademark-vs-file-licence distinction for brand marks; the aggregator licence
      trap; MCP/plugin sourcing discipline; what each licence class actually
      requires by way of attribution; and **brand marks** — Simple Icons vs theSVG
      vs Brandfetch compared on shape, coverage, colour and offline behaviour, plus
      Brandfetch's key setup, its two very different free tiers, hotlink/expiry
      constraints, and both MCP servers. Load when choosing a set, sourcing a
      company logo, or before shipping any brand mark.
    
    - [`references/inline-delivery.md`](references/inline-delivery.md) — delivery
      mechanism comparison and why icon fonts fail; the external-`<use>` CORS trap;
      `em` sizing and optical alignment; `currentColor` theming; the full
      accessibility checklist (both cases, target size, contrast, reduced motion);
      and SVGO ordering. Load when wiring icons into a page or debugging one that
      won't theme.
    
    - [`references/brand-variants.md`](references/brand-variants.md) — producing mono,
      greyscale, knockout and single-ink variants of a mark; why Rec.709 luminance
      beats an RGB average; when a CSS filter is right and when it is a lie; the three
      light/dark approaches and why the internal-media-query one usually breaks;
      logo-wall sizing by area rather than width; and logo `alt` conventions. Load
      when a mark needs a treatment it did not ship with.
    
    - [`references/favicons-and-app-icons.md`](references/favicons-and-app-icons.md) —
      the modern four-file set and the head block that serves it, why `rel="shortcut
      icon"` is meaningless, the theme-aware SVG favicon, Android maskable safe zones,
      and designing a mark down to 16px. Load for favicons, PWA icons or app icons.
    
    ## Scripts
    
    - `scripts/normalize-icon.py` — normalize a vendor SVG for inline themeable use.
      `--check` for a CI gate, `--stroke` for stroke families, `--symbol --id` for
      sprite assembly, `--json` for a machine-readable diff summary. Idempotent:
      re-running on a normalized file reports clean.
    
    ## Assets
    
    - `assets/sprite-template.svg` — commented `<symbol>` sprite scaffold to copy
      into a project, carrying the Safari-safe hiding pattern, per-symbol `viewBox`,
      `currentColor` defaults, and both filled and stroke examples.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related