How to Add a New Target#
The engine currently has 24 registered targets (irix targets lists them all).
Adding a 25th means writing one Target subclass, one anchor-table entry, and satisfying a test suite that already exists to catch dark/light drift before it ships.
This walks through all three, using irix.targets.terminals.KittyTarget as the reference implementation — the codebase’s own docs point there first for a reason: it’s the shape every later family imitated.
Anatomy of a Target#
irix.targets.Target (a pydantic model) declares the contract every subclass fills in:
class Target(pyd.BaseModel):
name: str
family: str
def render(self, mode: Mode) -> dict[str, str]:
"""Mapping of artifact path (relative to `out/`) to full file content."""
raise NotImplementedError
def deploy_dest(self, relpath: str) -> Path | None:
"""Local config path for `relpath`, or None if generate-only."""
return None
def anchor(self, role: str, mode: Mode) -> str:
"""Resolve this target's anchored `role` to a hex value under `mode`."""
return resolve_strict(palette_for(mode), Anchors.load().address(self.name, role, mode))
KittyTarget fills in exactly those three pieces, plus a name/family pair:
class KittyTarget(Target):
name: str = 'kitty'
family: str = 'terminals'
def render(self, mode: Mode) -> dict[str, str]:
lines = [_header(mode), '']
for section in _SECTIONS:
for key, role in section:
value = _LITERALS[key] if role is None else self.anchor(role, mode)
lines.append(f'{key:<{_KEY_WIDTH}} {value}')
lines.append('')
content = '\n'.join(lines).rstrip('\n') + '\n'
return {_FILENAMES[mode]: content}
def deploy_dest(self, relpath: str) -> Path | None:
xdg_config = os.environ.get('XDG_CONFIG_HOME', '').strip()
base = Path(xdg_config) if xdg_config else Path.home() / '.config'
return base / 'kitty' / Path(relpath).name
Three conventions worth copying exactly, not just the shape:
Module-level row tables (
_SECTIONS,_FILENAMES,_LITERALSaboveKittyTargetitself) instead of building the format inline inrender— every later target family (shell.py,editors.py,portable.py) follows the same split.self.anchor(role, mode)for every resolved value, never a bare hex literal inrender. It routes throughAnchors.load().address(...), which raisesKeyErroron a missing role rather than silently emitting an empty string — anchoring is explicit by design.deploy_destreads the environment live, on every call (os.environ.get(...)inside the method, not cached at import or__init__time) so tests — and operators — can monkeypatchHOME/XDG_CONFIG_HOMEper call and see the destination move with them.
Give it an anchor-table entry#
Anchors live in one YAML file per family under irix/data/anchors/ (terminals.yaml, shell.yaml, editors.yaml, portable.yaml), keyed by target name under a top-level targets: map.
Kitty’s own entry:
targets:
kitty:
foreground: slate-text2
background: slate-shape1
ansi_red: magma-text1
ansi_green: jade-sign1
# ...
Every value is a Palette.resolve-compatible address — almost always a bare hue-shade pair.
The one exception is a role whose Radix rung reads fine in dark but falls short of legible in light (or vice versa): that role’s value becomes a {dark: ..., light: ...} mapping instead of a plain string.
Real example, from anchors/editors.yaml’s vim entry:
targets:
vim:
function: { dark: cyan-sign1, light: cyan-text1 }
accent: { dark: amber-sign1, light: amber-text1 }
Anchors.address(target, role, mode) resolves either shape transparently — plain strings ignore mode entirely; the dict form picks its mode key (falling back to dark if somehow asked for a mode it doesn’t have).
Reach for this only when a role genuinely needs a different rung per mode, not as a default — most roles are one address serving both.
Register it#
register(KittyTarget())
called at module scope (not inside a function) is what puts a target into the live REGISTRY.
register asserts the name isn’t already taken — duplicate target names are a programming error, not a runtime condition to handle gracefully.
Nothing imports your new module by name, and nothing needs to: irix.targets.load_all_targets walks every module in the irix.targets package with pkgutil.iter_modules and imports each one, which fires every family module’s module-scope register(...) calls as a side effect.
cli and deploy both call load_all_targets() once before touching REGISTRY.
A brand-new irix/targets/<family>.py gets picked up automatically the moment it exists — no registration list to edit anywhere else.
Try it#
uv run irix generate <name>
uv run irix check <name>
generate renders both modes to out/ (or --out); check renders in memory and diffs against what’s already on disk — the same freshness gate CI runs, so committing a new target without regenerating out/ fails check immediately rather than shipping quietly stale.
The contract: tests/test_parity.py#
A structural suite enforces exactly what a new target must satisfy, independent of what the target actually renders. Six checks, four of which apply to every target unconditionally:
TestRegistryParity.test_both_modes_render_nonempty—render('dark')andrender('light')must both return a non-empty mapping, and every value in it must be non-empty content.Target.render’s own docstring states this as the contract ("Must be non-empty for both modes — the parity suite enforces this"); this test is that enforcement.TestRegistryParity.test_relpaths_live_under_family_dir— every relpath a target renders must start withf'{target.family}/'. If your target has a legitimate reason to also emit somewhere else (asvimdoes — see below), the exception goes in that test module’s_FAMILY_DIR_EXCEPTIONSdict, named explicitly, rather than the invariant being weakened for everyone.TestRegistryParity.test_dark_and_light_render_differently— dark and light output must not be byte-identical, unless the target is a deliberate single-artifact case (see below), in which case it’s named in that module’s_IDENTICAL_CONTENT_TARGETSset.TestDiskParity.test_every_rendered_relpath_exists_on_disk— every relpath either mode renders must exist undersource_out_root(). This is what actually catches “generated dark, forgot to commit light” (or forgot to runirix generateagain after an anchor edit).
Two more apply only if your target uses the shared lookup tables:
TestAnchorsIntegrity— if your target resolves roles throughAnchors.load(), every role it references must resolve to a hex value under both modes without raising.TestSemanticsIntegrity— the same, for any target resolving throughirix.semantics.Semanticsinstead of (or alongside)Anchors.
None of this is about role-level contrast floors — test_legibility.py owns WCAG legibility enforcement, described in How to Switch Between Dark and Light and the architecture reference.
test_parity.py only checks structure: both modes present, both non-empty, both on disk, every anchor resolvable.
The dual-mode-single-artifact exception#
Most targets split by filename — kitty.conf vs kitty-light.conf — so render('dark') and render('light') naturally return different mappings.
A few targets can’t split that way and aren’t supposed to: pymodule (out/python/irix.py carries both IRIX and IRIX_LIGHT), cssvars (one stylesheet, a :root/.dark split instead of a file split), and vim (real &background branching inside one file) each return one byte-identical mapping for both modes — the artifact inherently carries both palettes, so there is nothing left for the mode argument to vary.
vim is also the one target that emits two relpaths for the same content — the canonical editors/irix.vim and a load-bearing legacy shell/irix.vim copy — which is exactly what _FAMILY_DIR_EXCEPTIONS exists to document rather than special-case silently.
If your new target is genuinely this shape — one file, both palettes embedded, nothing left for mode to vary — both render calls should still return identical non-empty content (the “non-empty for both modes” invariant doesn’t go away), and you add the target’s name to _IDENTICAL_CONTENT_TARGETS (and _FAMILY_DIR_EXCEPTIONS too, if it also emits outside its own family directory) so test_parity.py treats the identical output as intentional instead of a regression.