Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
All notable changes to the equisdots theme-sync engine are documented here.
Dates use YYYY-MM-DD.

## [2026-09-30]

### Added
- VS Code target: palettes without a handcrafted theme in `xscriptor-themes`
(community/, user-created, renamed) now tint Code / Code - Insiders live
through a generated `workbench.colorCustomizations` +
`editor.tokenColorCustomizations` + `editor.semanticTokenColorCustomizations`
block (`themesync/vscode_theme.py`). The base theme is matched to the palette
luminance (Default Dark/Light Modern), bundled palettes keep their native
theme and remove the block, and user-defined customization keys are never
overwritten. Everything (themes and icons) is discovered at runtime from the
installed extension: no palette list is hardcoded.
- `scripts/check.sh`: validates the vscode mapping over every palette in the
checkout (hex colors and WCAG AA contrast).

## [2026-09-28]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Defaults target the equisdots desktop layout:
| `kitty` | `~/.config/kitty/themes/<slug>.conf` + include and active border in `kitty.conf` |
| `starship` | per-palette themes + fixed `~/.config/starship.toml` + `STARSHIP_CONFIG` in `.zshrc`/`.bashrc` |
| `xtop` | `~/.config/xtop/themes/<slug>.jsonc` + active theme (`xtop --ct`) |
| `vscode` | `workbench.colorTheme` / `workbench.iconTheme` in Code / Code - Insiders |
| `vscode` | `workbench.colorTheme` / `workbench.iconTheme` for palettes bundled in the xscriptor-themes extension; any other palette (community/, user-created) gets an intelligent live tint via generated `colorCustomizations` + token/semantic colors, in Code / Code - Insiders |
| `nvim` | `lua/themes/palettes.lua` + active-theme bootstrap |
| `browsers` | Brave/Beta prefs (`color_scheme2`, accent) + Firefox `user.js` |
| `opencode` | `~/.config/opencode/themes/<slug>.json` + active theme in `tui.json` |
Expand Down
34 changes: 34 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ if [ -n "$PALETTES" ]; then
fi
rm -f "$tmp"
echo "CLI smoke test: OK ($PALETTES)"

# vscode mapping: every palette must produce valid colors and readable
# foreground/background contrast (the dynamic tint path).
if ! PYTHONPATH="$PWD" python3 - "$PALETTES" <<'PY'
import re, sys
from pathlib import Path
from themesync import vscode_theme
from themesync.core import contrast, load_palettes, palette_bg_fg

HEX = re.compile(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$")
pals = load_palettes(Path(sys.argv[1]))
if len(pals) < 2:
print("vscode mapping: too few palettes (%d)" % len(pals))
sys.exit(1)
for pal in pals:
out = vscode_theme.build(pal)
wb = out["workbench"]
for key, value in wb.items():
if not HEX.match(value):
print("vscode mapping: bad color %s %s=%s" % (pal.get("slug"), key, value))
sys.exit(1)
bg, _ = palette_bg_fg(pal)
if contrast(bg, wb["foreground"]) < 4.5:
print("vscode mapping: low contrast on %s" % pal.get("slug"))
sys.exit(1)
if len(out["tokenRules"]) < 20 or len(out["semantic"]["rules"]) < 10:
print("vscode mapping: thin token map on %s" % pal.get("slug"))
sys.exit(1)
print("vscode mapping: OK (%d palettes)" % len(pals))
PY
then
echo "vscode mapping FAIL"
fail=1
fi
else
echo "note: no palette source found, CLI smoke test skipped" >&2
fi
Expand Down
211 changes: 187 additions & 24 deletions themesync/targets/vscode.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,220 @@
# ═══════════════════════════════════════════════════════════════════════════
# vscode — colorTheme + iconTheme in every installed variant.
# vscode — Code / Code - Insiders theming.
#
# The xscriptor-themes extension ships one color theme AND one icon theme per
# palette (id "<slug>-icons"). workbench.colorTheme and workbench.iconTheme are
# updated in the settings.json of Code and Code - Insiders; VS Code applies them
# live. The color theme name is the capitalized slug (x → "X", bogota → "Bogotá").
# Two regimes, decided dynamically per editor (nothing palette-specific):
#
# · palettes bundled by the xscriptor-themes extension ship a handcrafted
# theme: set workbench.colorTheme/iconTheme to it and remove any generated
# customizations (discovered by globbing the installed extension; future
# themes are picked up without touching this file).
#
# · any other palette (community/, user-created, renamed...) has no theme
# file: keep a neutral base theme for the current luminance and inject an
# intelligent `workbench.colorCustomizations` +
# `editor.tokenColorCustomizations` + `editor.semanticTokenColorCustomizations`
# generated from its base16 (themesync/vscode_theme.py). VS Code watches
# settings.json, so the change applies live in every window.
#
# The generated keys live in a marker-delimited block; switching back to a
# bundled palette removes it. User-defined customization keys are never
# overwritten: if one exists outside the block, ours for that key is skipped.
# ═══════════════════════════════════════════════════════════════════════════
from __future__ import annotations

import json
import re
from pathlib import Path

from .. import vscode_theme
from ..core import load_json

NAME = "vscode"
DESCRIPTION = "colorTheme + iconTheme in Code / Code - Insiders"
DESCRIPTION = "colorTheme + live tint (colorCustomizations) in Code / Code - Insiders"

VARIANTS = (
".config/Code/User/settings.json",
".config/Code - Insiders/User/settings.json",
)

# vendor dir -> extensions dir (same vendor naming as VARIANTS)
EXT_DIRS = {
"Code": ".vscode/extensions",
"Code - Insiders": ".vscode-insiders/extensions",
}

BEGIN = "// >>> equisdots theme-sync >>>"
END = "// <<< equisdots theme-sync <<<"

_BLOCK_RE = re.compile(
r"[ \t]*" + re.escape(BEGIN) + r".*?" + re.escape(END) + r"[ \t]*\n?",
re.S,
)
_MANAGED_KEYS = (
"workbench.colorCustomizations",
"editor.tokenColorCustomizations",
"editor.semanticTokenColorCustomizations",
)


def available(env) -> bool:
# Always: if no variant is installed, apply() does nothing.
return True


def _theme_name(slug: str) -> str:
if slug == "x":
return "X"
if slug == "bogota":
return "Bogotá"
return slug[:1].upper() + slug[1:]
# ── native theme discovery (dynamic; no palette list) ─────────────────────────

def _native(env, slug: str, vendor: str):
"""Display name of the bundled theme for `slug`, or None."""
root = Path(env.home) / EXT_DIRS.get(vendor, "")
if not root.is_dir():
return None
for ext in sorted(root.glob("xscriptor.xscriptor-themes-*")):
theme_file = ext / "themes" / ("%s.json" % slug)
if not theme_file.is_file():
continue
theme = load_json(theme_file, {}) or {}
return theme.get("name") or (slug[:1].upper() + slug[1:])
return None


def _icon(env, slug: str, vendor: str):
"""Icon theme for `slug` (falls back to the neutral 'x-icons')."""
root = Path(env.home) / EXT_DIRS.get(vendor, "")
if not root.is_dir():
return None
for ext in sorted(root.glob("xscriptor.xscriptor-themes-*")):
pkg = load_json(ext / "package.json", {}) or {}
ids = [t.get("id") for t in (pkg.get("contributes", {}).get("iconThemes") or [])]
if ("%s-icons" % slug) in ids:
return "%s-icons" % slug
if "x-icons" in ids:
return "x-icons"
return None


# ── settings.json (JSONC) helpers ─────────────────────────────────────────────

def _set_key(text: str, key: str, value: str) -> str:
"""Set a top-level JSON key (replace it or insert after the first '{')."""
pat = re.compile(r'"%s":[^,}]*' % re.escape(key))
"""Set a top-level JSON key (replace it in place or insert it)."""
encoded = json.dumps(value, ensure_ascii=False)
pat = re.compile(r'(?m)^([ \t]*)"%s"\s*:\s*[^,}\n]*' % re.escape(key))
if pat.search(text):
return pat.sub('"%s": "%s"' % (key, value), text, count=1)
return text.replace("{", '{\n "%s": "%s",' % (key, value), 1)
return pat.sub(lambda m: '%s"%s": %s' % (m.group(1), key, encoded), text, count=1)
idx = text.find("{")
if idx < 0:
return '{\n "%s": %s\n}\n' % (key, encoded)
rest = text[idx + 1:]
if rest.startswith("\n"):
rest = rest[1:]
comma = "" if rest.strip() in ("", "}") else ","
return text[:idx + 1] + '\n "%s": %s%s\n' % (key, encoded, comma) + rest


def _extract_object(text: str, key: str):
"""The top-level object value of `key`, or (None, None) if absent/invalid."""
m = re.search(r'"%s"\s*:\s*' % re.escape(key), text)
if not m:
return None, None
start = text.find("{", m.end())
if start < 0:
return None, None
depth = 0
for i in range(start, len(text)):
ch = text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1]), (m.start(), i + 1)
except Exception:
return None, None
return None, None


def _block(obj: dict, trailing_comma: bool) -> str:
"""Managed block: one indented `"key": value` line per key (no wrapper)."""
parts = []
for key, value in obj.items():
encoded = json.dumps(value, indent=4, ensure_ascii=False)
encoded = encoded.replace("\n", "\n ")
parts.append(' "%s": %s' % (key, encoded))
body = ",\n".join(parts)
if trailing_comma:
body += ","
return "%s\n%s\n%s" % (BEGIN, body, END)


def _strip_block(text: str) -> str:
"""Remove the managed block and normalize a brace-line first key."""
text = _BLOCK_RE.sub("", text)
m = re.match(r'\{\s*"', text)
if m and "\n" not in m.group(0):
text = "{\n" + text[1:].lstrip(" \t")
return text


def _insert_block(text: str, obj: dict) -> str:
idx = text.find("{")
if idx < 0:
return "{\n" + _block(obj, False) + "\n}\n"
rest = text[idx + 1:]
if rest.startswith("\n"):
rest = rest[1:]
needs_comma = rest.strip() not in ("", "}")
return text[:idx + 1] + "\n" + _block(obj, needs_comma) + "\n" + rest


# ── apply ─────────────────────────────────────────────────────────────────────

def apply(env) -> list:
out = []
theme = _theme_name(env.slug)
icons = "%s-icons" % env.slug
for rel in VARIANTS:
path = env.home / rel
if not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="replace")
text = _set_key(text, "workbench.colorTheme", theme)
text = _set_key(text, "workbench.iconTheme", icons)
env.write(path, text)
variant = path.parent.parent.name # "Code" / "Code - Insiders"
out.append("vscode (%s) → color '%s', icons '%s'" % (variant, theme, icons))
vendor = Path(rel).parent.parent.name # "Code" / "Code - Insiders"
original = path.read_text(encoding="utf-8", errors="replace")
text = _strip_block(original)

display = _native(env, env.slug, vendor)
icon = _icon(env, env.slug, vendor)
if display:
text = _set_key(text, "workbench.colorTheme", display)
if icon:
text = _set_key(text, "workbench.iconTheme", icon)
note = "theme '%s'%s" % (display, " + '%s'" % icon if icon else "")
else:
generated = vscode_theme.build(env.palette)
payload = {}
skipped = []
for key in _MANAGED_KEYS:
if _extract_object(text, key)[1] is not None:
skipped.append(key)
continue
if "workbench.colorCustomizations" not in skipped:
payload["workbench.colorCustomizations"] = generated["workbench"]
if "editor.tokenColorCustomizations" not in skipped:
payload["editor.tokenColorCustomizations"] = {
"textMateRules": generated["tokenRules"],
}
if "editor.semanticTokenColorCustomizations" not in skipped:
payload["editor.semanticTokenColorCustomizations"] = generated["semantic"]

base = "Default Light Modern" if env.light else "Default Dark Modern"
text = _set_key(text, "workbench.colorTheme", base)
if icon:
text = _set_key(text, "workbench.iconTheme", icon)
if payload:
text = _insert_block(text, payload)
note = "live tint for '%s' over '%s' (%d workbench keys, %d token rules%s)" % (
env.slug, base,
len(generated["workbench"]), len(generated["tokenRules"]),
"; skipped user keys: %s" % ", ".join(skipped) if skipped else "",
)

if text != original:
env.write(path, text)
out.append("vscode (%s) → %s" % (vendor, note))
return out
Loading
Loading