Claude Skill

svg-brand-tint-ops

Recolour, vectorise, and theme any SVG to a brand palette with a zero-dependency studio that emits a copy-paste CSS filter. Triggers on: recolour svg, brand tint an svg, duotone svg, recolour a diagram, cloudcraft/draw.io/figma svg to brand, svg css filter, png to svg, vectorise

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_svg-brand-tint-ops-3dfaf0b.zip · 104 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/svg-brand-tint-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

SVG Brand-Tint Studio

Recolour any SVG to a brand palette — theme-aware — and get a copy-paste CSS filter you can bake into an app. Also vectorises rasters (PNG/JPG → SVG) so a flat-image logo or a screenshot becomes an editable, recolourable SVG. One zero-dependency HTML file served by a ~90-line Node server. Nothing uploads — pixels are read in a local <canvas>.

Extracted from a real job: taming a CloudCraft AWS-diagram export into a house brand for an app's hosting page. It generalises to any third-party SVG export (draw.io, Figma, Mermaid, Excalidraw, icon sets) and any raster you need as vector.

When to reach for it

  • A diagram/icon/logo export is the wrong colours and has hundreds of inline fill/stroke values you don't want to hand-edit.
  • You want a duotone / tri-tone brand treatment and a light + dark variant from one palette swap.
  • You have a PNG/JPG and need clean vector paths (threshold, posterised, or colour-quantised — an in-browser Image-Trace).
  • You need the exact CSS filter: line (and a React snippet) to bake a theme-aware tint into a real page.

This is a skill, not a rule: it carries the operational knowledge (the colour math, the trace pipeline, the bake pattern) plus the working tool. For one-line "always do X" guidance there's no rule here — the value is the studio + the reference.

Run the studio

node skills/svg-brand-tint-ops/scripts/server.mjs        # → http://localhost:4322
# PORT=8080 node …/server.mjs      choose a port
# node …/server.mjs --root ./icons  serve your own folder of SVGs/PNGs
# node …/server.mjs --help          usage + exit codes

It serves the sibling assets/ (the studio + a generic sample.svg). Open the URL, then drop an SVG or PNG on the canvas (or use the Source panel's sample buttons). The left rail is accordion sections; the right is a scalable, pan/zoom viewport with a live readout of the exact filter: line and ramp hex.

What the panels do — Source (load SVG/PNG/JPG, drop, paste, samples) · Image Trace (raster → vector: B&W / Steps / Color, detail, smoothing, despeckle) · Tone Map (the N-stop brand ramp + presets + "palette from image") · Photographic (saturate/contrast/brightness/hue/sepia/blur/drop-shadow) · Strokes & Fills (outline-only, fill-only, stroke width/colour) · Typography (curated Google Fonts on the SVG's <text>, weight, size scale, tracking) · Geometry (rotate/flip/scale, strip fixed size) · Canvas (checker/solid/ light/dark, padding, grid) · Inspect (hover to highlight any element with its tag/id/class/colours) · Export (copy CSS / baked SVG / tokens / React snippet; download SVG or PNG @1–4×). Split (top bar) is a before/after divider.

How the recolour works (tri-tone)

Three stacked stages — the first two are one SVG <filter>, the third is CSS:

  1. feColorMatrix type="saturate" values="0" → collapse the source to a grey ramp.
  2. feComponentTransfer → remap that ramp per channel to the brand stops via tableValues (lines → ink, mid → accent, faces → canvas).
  3. filter: url(#tonemap) saturate(1.55) … → CSS tune on top.

Two stops = duotone (often washed); a third accent midstop = tri-tone that reads as designed. Custom stop positions are handled by resampling the ramp to a fixed table. Full derivation, N-stop math, and the sRGB vs linearRGB note: references/tri-tone-and-trace.md.

How the vectoriser works (PNG → SVG)

A from-scratch, accuracy-tuned engine (assets/trace-core.mjs, shared by the tool and the headless CLI): 2× supersample → alpha-aware segment into layers (threshold / posterise / median-cut → k-means-refined → merged colour) → interpolated sub-pixel iso-contours (fill-rule="evenodd" cuts holes) → closed-ring Douglas–Peucker → corner-split + Schneider least-squares Bézier fit (fairs out staircase noise into clean curves; straight runs and letter corners stay razor-sharp) → despeckle. Benchmarked on 24 real logos (RMSE ≈27→11.5, edge-F1 0.66→0.86) it reproduces bold/geometric/coloured marks near-perfectly (thin small text stays readable). It's one continuous move: PNG → trace → SVG → brand-tint. Algorithm + tuning knobs: references/tri-tone-and-trace.md §2.

Trace from the command line (needs sharp to decode; the browser tool needs nothing):

node skills/svg-brand-tint-ops/scripts/trace.mjs logo.png logo.svg --colors 6
node skills/svg-brand-tint-ops/scripts/trace.mjs --help    # flags + exit codes

Baking the result into an app

Render the SVG inline (an <img> sandboxes fonts + var() tokens + document filters). Because filter primitives can't read CSS variables, read your theme tokens with getComputedStyle(el).getPropertyValue('--ink') and write the feFunc* tableValues in JS — rebuild on theme change so light/dark re-tints for free. Strip the root width/height (keep viewBox) so it scales. Apply filter: url(#id) saturate(…). The studio's Export → React snippet emits this pre-filled; the full pattern + gotchas ledger is in references/tri-tone-and-trace.md §3–4.

Resources

Path What
scripts/server.mjs Zero-dep Node static server. node scripts/server.mjs --help for flags (--root, --port, exit codes). Serves assets/.
scripts/trace.mjs Headless PNG/JPG → SVG tracer over the shared engine. --help for options + exit codes. Needs sharp to decode (browser tool doesn't).
assets/trace-core.mjs The canonical, dependency-free trace engine (traceImage). Same code the tool runs inline; imported by the CLI. DEFAULTS at the top are the tuning knobs.
assets/index.html The studio — the whole tool in one self-contained file (kept classic-script so the file:// preview works). Editable PRESETS and FONTS maps near the top of the <script>; brand palettes are examples only.
assets/sample.svg Generic (brand-agnostic) diagram that auto-loads for a first-run demo.
references/tri-tone-and-trace.md The colour math, the trace algorithm, the theme-aware bake pattern, and a gotchas ledger.

Tenant-agnostic: every shipped palette (petrol, mono, blueprint, …) is an example. Swap the PRESETS map for your own tokens — nothing here is bound to a brand.

Files (claude-mods)
  • assets
    • index.html 99.3 KB · in bundle
    • sample.svg 1.5 KB · in bundle
    • trace-core.mjs 163.5 KB · in bundle
  • references
    • tri-tone-and-trace.md 11.2 KB
      # The colour math, the trace engine, and the theme-aware bake
      
      Everything the tuner does, explained so you can reproduce any piece of it in a
      production app without the UI. Three parts: the **tone-map** (recolour), the
      **image-trace** (raster → vector), and the **token-driven theme-aware pattern**
      (how to bake a live result into a real page). Ends with a gotchas ledger.
      
      ---
      
      ## 1. The tone map — desaturate → per-channel ramp → CSS tune
      
      Recolouring an arbitrary SVG to a brand palette is three stacked stages. The
      first two are one SVG `<filter>`; the third is a CSS `filter` on top.
      
      ### Stage 1 — kill the source hue
      
      ```xml
      <feColorMatrix type="saturate" values="0"/>
      ```
      
      Collapses every colour to its luminance grey. Now the image is a single grey
      ramp from black (0) to white (1) — a clean input the next stage can re-map.
      Skip this stage (the tuner's *Desaturate* toggle) only when you want the ramp to
      act on the source's raw R/G/B channels independently, which produces a channel-
      shift effect rather than a true tint.
      
      ### Stage 2 — remap the grey ramp to N brand colours
      
      `feComponentTransfer` runs an independent transfer function per channel. With
      `type="table"`, each `<feFunc*>`'s `tableValues` are read as **equally-spaced
      control points across the input [0,1]**, linearly interpolated between. So to
      map *grey → brand ramp* you feed each channel the ramp colours' channel values:
      
      ```xml
      <feComponentTransfer>
        <feFuncR type="table" tableValues="r0 r1 r2 … rN"/>   <!-- reds of each stop  -->
        <feFuncG type="table" tableValues="g0 g1 g2 … gN"/>   <!-- greens of each stop -->
        <feFuncB type="table" tableValues="b0 b1 b2 … bN"/>   <!-- blues of each stop  -->
      </feComponentTransfer>
      ```
      
      - **2 stops → duotone** (shadows → colour A, highlights → colour B).
      - **3 stops → tri-tone** (`lines → ink`, `midtones → accent`, `faces → canvas`).
        This is the sweet spot: two-stop duotones interpolate the *whole* midrange as a
        flat blend of the two endpoints and look washed / muddy. A third mid stop
        (usually the brand **accent**) gives the midtones somewhere to land and the
        result reads as intentional. **If your duotone looks washed out, add an accent
        midstop** — that single change is the difference between "greyscale with a
        colour cast" and "designed".
      - **4–5 stops** → quad/penta-tone for posterised, screen-print looks.
      
      **Arbitrary stop positions.** `tableValues` positions are *fixed at equal
      spacing*. To honour custom stop positions (a stop at 0.2 vs 0.5), don't try to
      encode position into the table — **resample**. Walk the sorted `(colour,
      position)` stops and emit a fixed number of equally-spaced samples (the tuner
      uses 33) by piecewise-linear interpolation along position. Equal-spaced stops
      resample to themselves, so it's a strict superset. This is `buildToneFilter()`
      in `index.html`.
      
      ### Stage 3 — CSS tune on top
      
      ```css
      filter: url(#tonemap) saturate(1.55) contrast(1.05) brightness(1);
      ```
      
      `saturate()` is the headline dial — the ramp interpolation slightly desaturates
      midtones, and lifting saturation back up (1.4–1.8) makes the tint sing. Add
      `contrast`/`brightness`/`hue-rotate`/`sepia`/`blur`/`drop-shadow` as needed. The
      order matters: `url(#tonemap)` **first** (it defines the colour), CSS functions
      after (they adjust it).
      
      ### `color-interpolation-filters`
      
      Set it explicitly on the filter. `sRGB` remaps in gamma space (what you see in
      the pickers — predictable, slightly punchier midtones). `linearRGB` (the SVG
      default!) remaps in linear light — physically "correct" blends but midtones read
      darker and often surprise designers. The tuner defaults to `sRGB` for WYSIWYG.
      
      ---
      
      ## 2. The image-trace engine — raster → clean vector
      
      The engine lives in **`assets/trace-core.mjs`** (`traceImage(imageData, opts)`), a
      pure, dependency-free module shared by the browser tool (fed a `<canvas>`
      ImageData) and the headless CLI (`scripts/trace.mjs`, fed decoded pixels) — one
      implementation, no drift. Nothing is uploaded; pixels are read locally. Tuned on
      a 24-logo accuracy bench (source PNG → trace → re-rasterise → per-pixel + edge
      fidelity), it moved mean RMSE from **≈27 → ≈11.5** and edge-F1 **0.66 → 0.86** vs
      the naïve first cut. The pipeline and *why each step earns its place*:
      
      1. **Supersample** the source to ~2× (`super`) before reading pixels. The logos
         are ~320 px; tracing at 2× places every edge crossing with sub-pixel accuracy,
         then the resolution-independent SVG is crisp at any size. 2× was the measured
         sweet spot — 3× only adds bytes.
      2. **Alpha-aware luminance + opacity** per pixel. Fully-transparent pixels are
         excluded from every layer, so a logo on a transparent/white background keeps
         its background transparent instead of tracing a giant rectangle.
      3. **Segment into layers** by mode:
         - **B&W** — one field, `luma < threshold`.
         - **Steps (posterise)** — `levels` cumulative luminance masks, lightest-largest
           at the bottom to darkest on top.
         - **Color** — **median-cut** seeds → **k-means (Lloyd) refinement** snaps the
           seeds onto the true flat colours (dissolving muddy anti-alias intermediates)
           → **palette merge** collapses entries within `mergeDist` (≈48), which removes
           the near-duplicate anti-alias fringe shades that otherwise become overlapping
           tint layers on a near-monochrome logo. Each surviving colour is one binary
           membership field, painted biggest-area first.
      4. **Interpolated iso-contours** (`isoContours`) — marching squares at iso 0.5 but
         with **linear interpolation of each edge crossing** between corner values, so
         the boundary lands at its true sub-pixel position rather than a pixel
         staircase. Consistent filled-on-right winding links segments into closed loops;
         `fill-rule="evenodd"` cuts holes. (Saddles 5/10 resolved "separate".)
      5. **Simplify** each loop with closed-ring **Douglas–Peucker** (`detail` epsilon,
         scaled by the supersample factor).
      6. **Corner-split + least-squares Bézier fit** (`fitPath`, Schneider's algorithm)
         — the key to sharp *and* smooth logos. Each vertex is classified corner-vs-curve
         by turn angle (`cornerDeg`); the ring is split at corners, and curve vertices
         are **Laplacian-faired** (`fair`). Then each smooth span is fitted with the
         **fewest cubic Béziers that stay within `fitErr` px** (recursive subdivision +
         Newton-Raphson reparameterisation) — which *averages out* the staircase noise
         into clean curves instead of interpolating through it. Straight runs and letter
         corners stay razor-sharp (lines, pinned); scripts and circles become genuinely
         smooth. This is what keeps **NGV a crisp rectangle** and **Grill'd a smooth
         script** from the same code. Feed it denser points (lower `detail`) for higher
         fidelity, raise `fitErr`/`smooth` for fewer, softer curves.
      7. **Despeckle** on true occupied pixel area.
      
      Output is a plain `<svg>` of `<path>`s that flows straight into the tone map:
      **PNG → trace → SVG → brand-tint** is one continuous move.
      
      **Honest scope.** A flat-art tracer, not a centreline/stroke tracer. It excels at
      logos, icons, flat marks, and posterised art — bold/filled/geometric logos come
      out near-perfect; very thin or small text (≈1 px strokes in a low-res source)
      stays readable but shows some edge roughness, the fundamental limit of
      vectorising an anti-aliased raster. Raise `colors`/`super`, lower `detail` for
      more fidelity at the cost of path count. Tuning knobs and their effects are the
      `DEFAULTS` block at the top of `trace-core.mjs`.
      
      ---
      
      ## 3. Baking a live result into an app (the theme-aware pattern)
      
      The whole point of a *token-driven* tint: define the ramp from your theme tokens
      and it re-themes for free on light/dark switch. The rules that make it work:
      
      ### Render the SVG **inline**, never as `<img>`
      
      An inline `<svg>` participates in the document: page `@font-face`s resolve on its
      `<text>`, `var(--token)` reads your CSS custom properties, and a CSS `filter:
      url(#id)` referencing an in-document `<filter>` applies. An `<img src="…svg">`
      **sandboxes** all of that — external font `@import`s are blocked, `var()` can't
      see your tokens, and a document filter id won't resolve. If your baked tint or
      font "just doesn't apply", this is almost always why.
      
      ### Filter primitives can't read CSS variables — build the ramp in JS
      
      `tableValues` is a static attribute; it cannot reference `var(--ink)`. So read
      the tokens at runtime and write the numbers:
      
      ```js
      function applyToneRamp(el, filter) {
        const cs = getComputedStyle(el);
        const stops = ['--ink', '--accent', '--canvas'].map(v => cs.getPropertyValue(v).trim());
        const ch = stops.map(hexToRgb01);                        // [[r,g,b], …]
        filter.querySelector('feFuncR').setAttribute('tableValues', ch.map(c => c[0]).join(' '));
        filter.querySelector('feFuncG').setAttribute('tableValues', ch.map(c => c[1]).join(' '));
        filter.querySelector('feFuncB').setAttribute('tableValues', ch.map(c => c[2]).join(' '));
        el.style.filter = `url(#${filter.id}) saturate(1.7)`;
      }
      ```
      
      Call it on mount **and whenever the theme changes** (a `MutationObserver` on
      `documentElement`'s `class`/`data-theme`, or your theme hook). On dark mode the
      same `--ink/--accent/--canvas` resolve to different hexes and the artwork
      re-tints with zero per-colour edits.
      
      ### Strip the root `width`/`height`
      
      Remove the fixed `width`/`height` from the `<svg>` (keep the `viewBox`) so it
      scales fluidly to its container via CSS. Add `preserveAspectRatio="xMidYMid
      meet"`. The tuner's *Strip fixed width/height* toggle does exactly this.
      
      The tuner's **Export → React snippet** emits this shape pre-filled with your
      current ramp.
      
      ---
      
      ## 4. Gotchas ledger
      
      | Symptom | Cause | Fix |
      |---|---|---|
      | Duotone looks washed / muddy | Only 2 stops — midrange is a flat blend | Add an **accent midstop** (tri-tone) |
      | Midtones darker than the pickers suggest | `color-interpolation-filters` defaulting to `linearRGB` | Set `sRGB` explicitly on the `<filter>` |
      | Font / `var()` tokens don't apply | SVG loaded as `<img>` — sandboxed | Render **inline** |
      | `tableValues` won't pick up `--ink` | Filter primitives can't read CSS vars | Read tokens in JS, write the numbers; rebuild on theme change |
      | SVG won't scale to its box | Fixed `width`/`height` on root | Strip them, keep `viewBox` + `preserveAspectRatio` |
      | Trace output is inverted (art became holes) | `luma < threshold` selects the *dark* pixels | Toggle **Invert luminance** or move the threshold |
      | Trace is slow / path count explodes | Working resolution too high, `detail` too low | Lower `resolution`, raise `detail`, raise `despeckle` |
      | Traced holes filled solid | Missing even-odd rule | `fill-rule="evenodd"` on each layer's path |
      | PNG export blank / "tainted canvas" | The rasterised SVG referenced a cross-origin resource | Inline/embed resources; trace output and self-contained SVGs are safe |
      | PNG export missing the CSS look | `ctx.filter` didn't get the photographic chain | Bake tone-map into the SVG, apply the CSS functions via `ctx.filter` before `drawImage` |
      | Recolour ignores some shapes | They carry inline `fill`/`stroke` the CSS filter still tints — but a `<style>` override may need `!important` | Scope an injected `<style>` and use `!important` (the tuner's Strokes & Fills does this) |
      
  • scripts
    • server.mjs 5.1 KB · in bundle
    • trace.mjs 7.4 KB · in bundle
  • tests
    • run.sh 7.3 KB
      #!/usr/bin/env bash
      # Offline self-test for svg-brand-tint-ops: the server contract + that it serves
      # the studio (tri-tone filter + trace engine) and the sample.
      #
      # Self-contained, no network. Resolves paths relative to itself so it runs both
      # in the repo and once installed to ~/.claude/skills/svg-brand-tint-ops/.
      # Skips gracefully (exit 0) when node or curl is unavailable.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass (or skipped on unsupported host), 1 one or more failures
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      SERVER="$SKILL/scripts/server.mjs"
      INDEX="$SKILL/assets/index.html"
      SAMPLE="$SKILL/assets/sample.svg"
      
      PASS=0; FAIL=0
      ok(){ PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no(){ FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      expect_exit(){ [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
      has(){ case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
      
      echo "=== svg-brand-tint-ops self-test ==="
      
      # ── static content sanity (no runtime needed) ──────────────────────────────
      [[ -f "$INDEX" ]] && ok "index.html present" || no "index.html missing"
      idx="$(cat "$INDEX" 2>/dev/null)"
      has "index carries the tri-tone filter" "feComponentTransfer" "$idx"
      has "index references the tone-map filter" "url(#tonemap)" "$idx"
      has "index ships the iso-contour tracer" "isoContours" "$idx"
      has "index has the Image Trace panel" "Image Trace" "$idx"
      [[ -f "$SAMPLE" ]] && ok "sample.svg present" || no "sample.svg missing"
      
      # --- section-map drift gate (assets/index.html: guard comment ↔ // === markers) ---
      # index.html is a deliberately single-file studio; its top <script> guard
      # comment lists the `// === NAME ===` banner sections so the file is
      # navigable. This gate keeps the guard list and the body markers in sync
      # bidirectionally and FAILS LOUDLY if either side parses to zero names — the
      # classic rot mode where a guard-comment/marker format change silently yields
      # an empty list and the check would otherwise vacuously pass.
      map_names="$(awk '
        /Sections \(grep/ { cap=1; sub(/.*:[[:space:]]*/,"",$0); blob=blob $0 " "; if ($0 ~ /\*\//) cap=0; next }
        cap { if ($0 ~ /\*\//) { cap=0; next } blob=blob $0 " " }
        END { gsub(/·/,"\n",blob); n=split(blob,a,"\n");
              for (i=1;i<=n;i++){ s=a[i]; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); if (s!="") print s } }
      ' "$INDEX")"
      mark_names="$(grep -E '^// === .* ===$' "$INDEX" | sed -E 's|^// === (.*) ===$|\1|')"
      dc="$(printf '%s\n' "$map_names"  | grep -c . || true)"
      mc="$(printf '%s\n' "$mark_names" | grep -c . || true)"
      # empty-parse guard: either side unparseable is a hard fail (never a silent pass)
      if [[ "$dc" -gt 0 && "$mc" -gt 0 ]]; then
        ok "section-map parses (guard=$dc names, body=$mc markers)"
      else
        no "section-map EMPTY PARSE (guard=$dc, body=$mc) — guard comment or marker format changed"
      fi
      # forward: every guard-listed section has a matching // === marker
      fwd_miss=""
      while IFS= read -r n; do
        [[ -z "$n" ]] && continue
        grep -Fxq -- "$n" <<< "$mark_names" || fwd_miss="$fwd_miss $n"
      done <<< "$map_names"
      if [[ -z "$fwd_miss" ]]; then
        ok "forward: every guard-listed section has a // === marker"
      else
        no "forward: guard sections with no marker:${fwd_miss}"
      fi
      # reverse: every // === marker is present in the guard list
      rev_miss=""
      while IFS= read -r n; do
        [[ -z "$n" ]] && continue
        grep -Fxq -- "$n" <<< "$map_names" || rev_miss="$rev_miss $n"
      done <<< "$mark_names"
      if [[ -z "$rev_miss" ]]; then
        ok "reverse: every // === marker is listed in the guard comment"
      else
        no "reverse: body markers missing from guard:${rev_miss}"
      fi
      
      # ── runtime: needs node + curl ─────────────────────────────────────────────
      if ! command -v node >/dev/null 2>&1; then echo "  SKIP  node not found — runtime checks skipped"; echo "=== $PASS passed, $FAIL failed ==="; [[ "$FAIL" -eq 0 ]] || exit 1; exit 0; fi
      if ! command -v curl >/dev/null 2>&1; then echo "  SKIP  curl not found — runtime checks skipped"; echo "=== $PASS passed, $FAIL failed ==="; [[ "$FAIL" -eq 0 ]] || exit 1; exit 0; fi
      
      # server contract (offline, fast)
      node "$SERVER" --help >/dev/null 2>&1;  expect_exit "server --help" 0 $?
      node "$SERVER" --bogus >/dev/null 2>&1; expect_exit "server bad flag -> 2" 2 $?
      EMPTY="$(mktemp -d)"; node "$SERVER" --root "$EMPTY" >/dev/null 2>&1; expect_exit "server no-web-root -> 5" 5 $?; rmdir "$EMPTY" 2>/dev/null
      
      # ── engine (trace-core.mjs) + headless CLI (trace.mjs) ─────────────────────
      CORE="$SKILL/assets/trace-core.mjs"; CLI="$SKILL/scripts/trace.mjs"
      node --check "$CORE" >/dev/null 2>&1; expect_exit "trace-core.mjs compiles" 0 $?
      node --check "$CLI"  >/dev/null 2>&1; expect_exit "trace.mjs compiles" 0 $?
      # engine smoke — trace a synthetic 8x8 two-colour image, expect a valid SVG
      sm="$(cd "$SKILL" && node --input-type=module -e 'import("./assets/trace-core.mjs").then(m=>{const w=8,h=8,d=new Uint8ClampedArray(w*h*4);for(let y=0;y<h;y++)for(let x=0;x<w;x++){const i=(y*w+x)*4,v=x<4?0:255;d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=255;}const s=m.traceImage({data:d,width:w,height:h},{mode:"color",colors:2});process.stdout.write((s.includes("<path")&&s.includes("</svg>"))?"OK":"BAD");}).catch(()=>process.stdout.write("ERR"))' 2>&1)"
      case "$sm" in *OK*) ok "trace-core traces a synthetic image";; *) no "trace-core smoke ($sm)";; esac
      # CLI contract
      node "$CLI" --help >/dev/null 2>&1;            expect_exit "trace.mjs --help" 0 $?
      node "$CLI" x.png --bogus >/dev/null 2>&1;     expect_exit "trace.mjs bad flag -> 2" 2 $?
      node "$CLI" /no/such-file.png >/dev/null 2>&1; expect_exit "trace.mjs missing input -> 3" 3 $?
      node "$CLI" "$INDEX" "$(mktemp -u).svg" >/dev/null 2>&1; rc=$?
      case "$rc" in 5) ok "trace.mjs missing-dep -> 5 (needs sharp; browser tool doesn't)";; 1) ok "trace.mjs decoder present (non-image input -> 1)";; *) no "trace.mjs decoder path (got $rc)";; esac
      
      # boot the server on an ephemeral port and probe it
      LOG="$(mktemp)"; PORT_FILE="$(mktemp)"
      node "$SERVER" --port 0 >/dev/null 2>"$LOG" &
      SRV=$!
      trap 'kill "$SRV" >/dev/null 2>&1' EXIT
      PORT=""
      for _ in $(seq 1 40); do
        PORT="$(sed -n 's#.*http://localhost:\([0-9]\{1,\}\)/.*#\1#p' "$LOG" | head -1)"
        [[ -n "$PORT" ]] && break
        sleep 0.1
      done
      
      if [[ -z "$PORT" ]]; then
        no "server printed a listening URL"; cat "$LOG" >&2
      else
        ok "server bound ephemeral port $PORT"
        code="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/" 2>/dev/null)"
        expect_exit "GET / -> 200" 200 "$code"
        body="$(curl -s "http://localhost:$PORT/" 2>/dev/null)"
        has "served index has tri-tone filter" "feComponentTransfer" "$body"
        has "served index has the tracer" "isoContours" "$body"
        scode="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/sample.svg" 2>/dev/null)"
        expect_exit "GET /sample.svg -> 200" 200 "$scode"
        tcode="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/../server.mjs" 2>/dev/null)"
        case "$tcode" in 403|404) ok "path traversal blocked (../server.mjs -> $tcode)";; *) no "path traversal not blocked (got $tcode)";; esac
      fi
      kill "$SRV" >/dev/null 2>&1; trap - EXIT
      rm -f "$LOG" "$PORT_FILE" 2>/dev/null
      
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]] || exit 1
      
  • SKILL.md 7 KB
    ---
    name: svg-brand-tint-ops
    description: "Recolour, vectorise, and theme any SVG to a brand palette with a zero-dependency studio that emits a copy-paste CSS filter. Triggers on: recolour svg, brand tint an svg, duotone svg, recolour a diagram, cloudcraft/draw.io/figma svg to brand, svg css filter, png to svg, vectorise a logo, image trace."
    license: MIT
    metadata:
      author: claude-mods
      related-skills: "color-ops, genart-ops, mapbox-ops"
    ---
    
    # SVG Brand-Tint Studio
    
    Recolour **any** SVG to a brand palette — theme-aware — and get a copy-paste CSS
    filter you can bake into an app. Also **vectorises rasters** (PNG/JPG → SVG) so a
    flat-image logo or a screenshot becomes an editable, recolourable SVG. One
    zero-dependency HTML file served by a ~90-line Node server. Nothing uploads —
    pixels are read in a local `<canvas>`.
    
    Extracted from a real job: taming a CloudCraft AWS-diagram export into a house
    brand for an app's hosting page. It generalises to any third-party SVG export
    (draw.io, Figma, Mermaid, Excalidraw, icon sets) and any raster you need as
    vector.
    
    ## When to reach for it
    
    - A **diagram/icon/logo export** is the wrong colours and has hundreds of inline
      `fill`/`stroke` values you don't want to hand-edit.
    - You want a **duotone / tri-tone** brand treatment and a light + dark variant
      from one palette swap.
    - You have a **PNG/JPG** and need clean **vector paths** (threshold, posterised,
      or colour-quantised — an in-browser Image-Trace).
    - You need the **exact CSS `filter:` line** (and a React snippet) to bake a
      theme-aware tint into a real page.
    
    This is a **skill, not a rule**: it carries the operational knowledge (the
    colour math, the trace pipeline, the bake pattern) plus the working tool. For
    one-line "always do X" guidance there's no rule here — the value is the studio +
    the reference.
    
    ## Run the studio
    
    ```bash
    node skills/svg-brand-tint-ops/scripts/server.mjs        # → http://localhost:4322
    # PORT=8080 node …/server.mjs      choose a port
    # node …/server.mjs --root ./icons  serve your own folder of SVGs/PNGs
    # node …/server.mjs --help          usage + exit codes
    ```
    
    It serves the sibling `assets/` (the studio + a generic `sample.svg`). Open the
    URL, then **drop an SVG or PNG** on the canvas (or use the Source panel's sample
    buttons). The left rail is accordion sections; the right is a scalable,
    pan/zoom viewport with a live readout of the exact `filter:` line and ramp hex.
    
    **What the panels do** — *Source* (load SVG/PNG/JPG, drop, paste, samples) ·
    *Image Trace* (raster → vector: B&W / Steps / Color, detail, smoothing,
    despeckle) · *Tone Map* (the N-stop brand ramp + presets + "palette from image")
    · *Photographic* (saturate/contrast/brightness/hue/sepia/blur/drop-shadow) ·
    *Strokes & Fills* (outline-only, fill-only, stroke width/colour) · *Typography*
    (curated Google Fonts on the SVG's `<text>`, weight, size scale, tracking) ·
    *Geometry* (rotate/flip/scale, strip fixed size) · *Canvas* (checker/solid/
    light/dark, padding, grid) · *Inspect* (hover to highlight any element with its
    tag/id/class/colours) · *Export* (copy CSS / baked SVG / tokens / React snippet;
    download SVG or PNG @1–4×). `Split` (top bar) is a before/after divider.
    
    ## How the recolour works (tri-tone)
    
    Three stacked stages — the first two are one SVG `<filter>`, the third is CSS:
    
    1. `feColorMatrix type="saturate" values="0"` → collapse the source to a grey ramp.
    2. `feComponentTransfer` → remap that ramp per channel to the brand stops via
       `tableValues` (`lines → ink`, `mid → accent`, `faces → canvas`).
    3. `filter: url(#tonemap) saturate(1.55) …` → CSS tune on top.
    
    **Two stops = duotone (often washed); a third accent midstop = tri-tone that
    reads as designed.** Custom stop positions are handled by resampling the ramp to
    a fixed table. Full derivation, N-stop math, and the `sRGB` vs `linearRGB` note:
    [references/tri-tone-and-trace.md](references/tri-tone-and-trace.md).
    
    ## How the vectoriser works (PNG → SVG)
    
    A from-scratch, accuracy-tuned engine ([assets/trace-core.mjs](assets/trace-core.mjs),
    shared by the tool and the headless CLI): **2× supersample** → alpha-aware
    segment into layers (threshold / posterise / **median-cut → k-means-refined →
    merged** colour) → **interpolated sub-pixel iso-contours** (`fill-rule="evenodd"`
    cuts holes) → closed-ring **Douglas–Peucker** → **corner-split + Schneider
    least-squares Bézier fit** (fairs out staircase noise into clean curves; straight
    runs and letter corners stay razor-sharp) → despeckle. Benchmarked on 24 real
    logos (RMSE ≈27→11.5, edge-F1 0.66→0.86) it reproduces bold/geometric/coloured
    marks near-perfectly (thin small text stays readable). It's one
    continuous move: **PNG → trace → SVG → brand-tint**. Algorithm + tuning knobs:
    [references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) §2.
    
    Trace from the command line (needs `sharp` to decode; the browser tool needs
    nothing):
    
    ```bash
    node skills/svg-brand-tint-ops/scripts/trace.mjs logo.png logo.svg --colors 6
    node skills/svg-brand-tint-ops/scripts/trace.mjs --help    # flags + exit codes
    ```
    
    ## Baking the result into an app
    
    Render the SVG **inline** (an `<img>` sandboxes fonts + `var()` tokens + document
    filters). Because filter primitives can't read CSS variables, read your theme
    tokens with `getComputedStyle(el).getPropertyValue('--ink')` and write the
    `feFunc*` `tableValues` in JS — rebuild on theme change so light/dark re-tints
    for free. Strip the root `width/height` (keep `viewBox`) so it scales. Apply
    `filter: url(#id) saturate(…)`. The studio's **Export → React snippet** emits
    this pre-filled; the full pattern + gotchas ledger is in
    [references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) §3–4.
    
    ## Resources
    
    | Path | What |
    |---|---|
    | [scripts/server.mjs](scripts/server.mjs) | Zero-dep Node static server. `node scripts/server.mjs --help` for flags (`--root`, `--port`, exit codes). Serves `assets/`. |
    | [scripts/trace.mjs](scripts/trace.mjs) | Headless PNG/JPG → SVG tracer over the shared engine. `--help` for options + exit codes. Needs `sharp` to decode (browser tool doesn't). |
    | [assets/trace-core.mjs](assets/trace-core.mjs) | The canonical, dependency-free trace engine (`traceImage`). Same code the tool runs inline; imported by the CLI. `DEFAULTS` at the top are the tuning knobs. |
    | [assets/index.html](assets/index.html) | The studio — the whole tool in one self-contained file (kept classic-script so the `file://` preview works). Editable `PRESETS` and `FONTS` maps near the top of the `<script>`; brand palettes are examples only. |
    | [assets/sample.svg](assets/sample.svg) | Generic (brand-agnostic) diagram that auto-loads for a first-run demo. |
    | [references/tri-tone-and-trace.md](references/tri-tone-and-trace.md) | The colour math, the trace algorithm, the theme-aware bake pattern, and a gotchas ledger. |
    
    **Tenant-agnostic:** every shipped palette (petrol, mono, blueprint, …) is an
    example. Swap the `PRESETS` map for your own tokens — nothing here is bound to a
    brand.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related