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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/svg-brand-tint-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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/strokevalues 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:
feColorMatrix type="saturate" values="0"→ collapse the source to a grey ramp.feComponentTransfer→ remap that ramp per channel to the brand stops viatableValues(lines → ink,mid → accent,faces → canvas).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.
Reviews (0)
No reviews yet.
No comments yet.