ted-craft
ui

SVG currentColor

After an agent writes an SVG, normalize hardcoded stroke and fill colors to currentColor so icons inherit theme text color.

hookdev-ted
ted-craft — zsh
$ npx ted-craft add svg-current-color -a cursor -y
# SVG currentColor

After an agent writes an SVG file, this hook rewrites hardcoded `stroke` and `fill` colors to `currentColor` so icons pick up your theme text color.

## Why use it

Agents often emit SVGs with fixed hex colors. Those icons ignore dark mode and parent text color. With `currentColor`, the SVG follows CSS `color` like the rest of your UI.

## Install

This is a **hook**, not a skill — you still add it with `ted-craft add` (same CLI). Run the command in the **app project** where you want the hook (the folder that should get `.cursor/`), not as a global skill install.

```bash
npx ted-craft add svg-current-color -a cursor -y
```

That writes into the current project:

- `.cursor/hooks.json` — registers the `afterFileEdit` hook (merged with any hooks you already have)
- `.cursor/hooks/svg-current-color.mjs` — the normalizer script

Pass `-g` only if you intentionally want Cursor’s user-level hooks directory. The packaged command path is project-relative (`.cursor/hooks/...`), so project install is the usual choice.

### Manual install

If the CLI is unavailable, copy from the registry package:

1. Create `.cursor/hooks/` in your project
2. Copy `svg-current-color.mjs` into `.cursor/hooks/`
3. Merge this entry into `.cursor/hooks.json` (keep any hooks you already have):

```json
{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      {
        "command": "node .cursor/hooks/svg-current-color.mjs",
        "matcher": "Write"
      }
    ]
  }
}
```

## What it does

On Cursor `afterFileEdit` when the edit matcher is `Write`:

1. Reads the edited path from the hook payload
2. Skips anything that is not a `.svg` under a workspace root
3. Replaces hardcoded `stroke` / `fill` attributes and simple inline styles with `currentColor`
4. Leaves `none` and existing `currentColor` values alone

## Safety

This hook is local-only by design:

- No network, no shell, no downloads
- Writes only the edited SVG, and only if it resolves inside a workspace root
- Skips symlinks that point outside the workspace
- Skips files larger than 2 MiB
- Exits quietly on errors so it never blocks the agent

Review the script on this page before you install — hooks run on your machine.

## Caveats

- Runs on `Write` via `afterFileEdit` only
- If you need brand-specific SVG colors, keep them out of agent-generated icons or re-apply them after the hook runs
- `fill="none"` is preserved so hollow icons stay hollow

SVG currentColor

After an agent writes an SVG file, this hook rewrites hardcoded stroke and fill colors to currentColor so icons pick up your theme text color.

Why use it

Agents often emit SVGs with fixed hex colors. Those icons ignore dark mode and parent text color. With currentColor, the SVG follows CSS color like the rest of your UI.

Install

This is a hook, not a skill — you still add it with ted-craft add (same CLI). Run the command in the app project where you want the hook (the folder that should get .cursor/), not as a global skill install.

npx ted-craft add svg-current-color -a cursor -y

That writes into the current project:

  • .cursor/hooks.json — registers the afterFileEdit hook (merged with any hooks you already have)
  • .cursor/hooks/svg-current-color.mjs — the normalizer script

Pass -g only if you intentionally want Cursor’s user-level hooks directory. The packaged command path is project-relative (.cursor/hooks/...), so project install is the usual choice.

Manual install

If the CLI is unavailable, copy from the registry package:

  1. Create .cursor/hooks/ in your project
  2. Copy svg-current-color.mjs into .cursor/hooks/
  3. Merge this entry into .cursor/hooks.json (keep any hooks you already have):
{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      {
        "command": "node .cursor/hooks/svg-current-color.mjs",
        "matcher": "Write"
      }
    ]
  }
}

What it does

On Cursor afterFileEdit when the edit matcher is Write:

  1. Reads the edited path from the hook payload
  2. Skips anything that is not a .svg under a workspace root
  3. Replaces hardcoded stroke / fill attributes and simple inline styles with currentColor
  4. Leaves none and existing currentColor values alone

Safety

This hook is local-only by design:

  • No network, no shell, no downloads
  • Writes only the edited SVG, and only if it resolves inside a workspace root
  • Skips symlinks that point outside the workspace
  • Skips files larger than 2 MiB
  • Exits quietly on errors so it never blocks the agent

Review the script on this page before you install — hooks run on your machine.

Caveats

  • Runs on Write via afterFileEdit only
  • If you need brand-specific SVG colors, keep them out of agent-generated icons or re-apply them after the hook runs
  • fill="none" is preserved so hollow icons stay hollow

Hook config

hooks.json
{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      {
        "command": "node .cursor/hooks/svg-current-color.mjs",
        "matcher": "Write"
      }
    ]
  }
}

Hook script: svg-current-color.mjs

svg-current-color.mjs
#!/usr/bin/env node
/**
 * SVG currentColor normalizer (Cursor afterFileEdit hook).
 *
 * Threat model: local-only. Reads stdin JSON from Cursor, and may rewrite a
 * single .svg file under a workspace root. No network, no shell, no eval.
 * Preserves fill="none" and values already set to currentColor.
 */
import { readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, resolve, sep } from "node:path";

const RESERVED = new Set(["none", "currentcolor", "currentColor"]);
const MAX_BYTES = 2 * 1024 * 1024;

/**
 * @param {unknown} input
 * @returns {string | null}
 */
function resolveFilePath(input) {
  if (!input || typeof input !== "object") return null;
  const raw = /** @type {{ file_path?: unknown }} */ (input).file_path;
  if (typeof raw !== "string" || !raw.trim()) return null;
  if (isAbsolute(raw)) return resolve(raw);
  const roots = /** @type {{ workspace_roots?: unknown }} */ (input).workspace_roots;
  const root = Array.isArray(roots) && typeof roots[0] === "string" ? roots[0] : null;
  return root ? resolve(join(root, raw)) : resolve(raw);
}

/**
 * @param {string} filePath
 * @param {unknown} input
 * @returns {boolean}
 */
function isInsideWorkspace(filePath, input) {
  const roots = /** @type {{ workspace_roots?: unknown }} */ (input).workspace_roots;
  if (!Array.isArray(roots) || roots.length === 0) return false;

  let realFile;
  try {
    // Follows symlinks — rejects targets outside the workspace.
    realFile = realpathSync(filePath);
  } catch {
    // Missing path: jail on the lexical absolute path.
    realFile = resolve(filePath);
  }

  for (const root of roots) {
    if (typeof root !== "string" || !root.trim()) continue;
    let realRoot;
    try {
      realRoot = realpathSync(root);
    } catch {
      realRoot = resolve(root);
    }
    const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
    if (realFile === realRoot || realFile.startsWith(prefix)) {
      return true;
    }
  }
  return false;
}

/**
 * @param {string} value
 */
function isReserved(value) {
  return RESERVED.has(value.trim());
}

/**
 * @param {string} svg
 */
function normalizeSvgColors(svg) {
  let result = svg;

  result = result.replace(
    /\bstroke=(["'])([^"']*)\1/gi,
    (_, quote, value) =>
      isReserved(value) ? `stroke=${quote}${value}${quote}` : 'stroke="currentColor"',
  );

  result = result.replace(
    /\bfill=(["'])([^"']*)\1/gi,
    (_, quote, value) =>
      isReserved(value) ? `fill=${quote}${value}${quote}` : 'fill="currentColor"',
  );

  result = result.replace(
    /\bstroke:\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|[a-z]+)\s*(;|(?=\s|"))/gi,
    (match, color, tail) =>
      isReserved(color) ? match : `stroke:currentColor${tail}`,
  );

  result = result.replace(
    /\bfill:\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|[a-z]+)\s*(;|(?=\s|"))/gi,
    (match, color, tail) =>
      isReserved(color) ? match : `fill:currentColor${tail}`,
  );

  return result;
}

function main() {
  try {
    const input = JSON.parse(readFileSync(0, "utf8"));
    const filePath = resolveFilePath(input);

    if (!filePath || !filePath.toLowerCase().endsWith(".svg")) {
      process.exit(0);
    }

    if (!isInsideWorkspace(filePath, input)) {
      process.exit(0);
    }

    let st;
    try {
      st = statSync(filePath);
    } catch {
      process.exit(0);
    }

    if (!st.isFile() || st.size > MAX_BYTES) {
      process.exit(0);
    }

    const before = readFileSync(filePath, "utf8");
    const after = normalizeSvgColors(before);

    if (after !== before) {
      writeFileSync(filePath, after, "utf8");
    }
  } catch {
    // Fail closed: never break the agent loop.
  }

  process.exit(0);
}

main();

On this page