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, _LITERALS above KittyTarget itself) instead of building the format inline in render — 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 in render. It routes through Anchors.load().address(...), which raises KeyError on a missing role rather than silently emitting an empty string — anchoring is explicit by design.

  • deploy_dest reads the environment live, on every call (os.environ.get(...) inside the method, not cached at import or __init__ time) so tests — and operators — can monkeypatch HOME/XDG_CONFIG_HOME per 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') and render('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 with f'{target.family}/'. If your target has a legitimate reason to also emit somewhere else (as vim does — see below), the exception goes in that test module’s _FAMILY_DIR_EXCEPTIONS dict, 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_TARGETS set.

  • TestDiskParity.test_every_rendered_relpath_exists_on_disk — every relpath either mode renders must exist under source_out_root(). This is what actually catches “generated dark, forgot to commit light” (or forgot to run irix generate again after an anchor edit).

Two more apply only if your target uses the shared lookup tables:

  • TestAnchorsIntegrity — if your target resolves roles through Anchors.load(), every role it references must resolve to a hex value under both modes without raising.

  • TestSemanticsIntegrity — the same, for any target resolving through irix.semantics.Semantics instead 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.