Architecture: How Irix Is Put Together#

This page is Explanation, not Reference: it is about why the system is shaped the way it is, not the call signatures (those live in the API pages, starting from The Irix Color Palette‘s Reference section). Three things compose to make Irix: an architectonic palette (chambers, hues, shades), a semantic role map on top of it (what each color is for), and a targets engine that turns both into deployed artifacts across every application Irix touches.

The palette: five chambers, four hues, twelve shades#

Color.IRIX_MAP (irix/Color.py) is the palette’s spine:

The five chambers and their four hues each#

Chamber

Hues

Working pattern in semantics.yaml

UN

gray, sage, clay, gold

Neutral foundation – backgrounds, gutters, default text (sage), base constants (gold).

SUB

slate, jade, indigo, cyan

Structural/functional – editor chrome (ruler, guide, cursor, selection), keywords (jade), types and links (indigo), primary UI accent (cyan).

SELF

sand, magma, orange, amber

Expressive/emphasis – comments (sand), headings and warnings (amber), attribute names and parameters (orange).

META

mauve, denim, pink, purple

Secondary/annotation – doc comments (mauve), imports (purple), the second half of the bracket-rotation.

DATA

red, blue, yellow, green

Status/diagnostic signal – error/success/warning/info roles and the full terminal ANSI palette map directly onto this chamber.

Twelve shades per hue (Color.SHADE_NAMES: sheet1/sheet2 … text1/text2) walk each hue from near-background to near-foreground, mirroring Radix Colors’ twelve-step scale – the source Irix distills from (see Color.py’s module docstring). Color.new addresses any entry by chamber/hue/shade indices or by flexible strings ('slate-sheet2', 'SLATE-SHEET2', (1, 0, 1)); Palette.resolve does the same resolution against a loaded dict[Hue, Swatch], so Color and Palette are two views of the identical addressing scheme – one indexed, one string-keyed.

The DATA chamber’s role is the one directly confirmed in code, not just inferred from usage: Semantics.classify_workbench routes every terminal.ansi* VSCode key through _ANSI_ROLES onto terminal.ansi-{color} role keys, and semantics.yaml resolves every one of those to a DATA-chamber hue (red, green, yellow, blue) – the chamber is the canonical error/success/warning/info signal set, not a loose color-psychology gesture at one. The other four chambers’ “meaning” above is a read of the actual role table (tallied from irix/data/semantics.yaml), offered as the working pattern rather than a declared law – nothing in the source asserts it as policy the way the DATA mapping is asserted.

Further reading on the color-psychology background this design drew on: Color Semantics & Psychology: Research Summary. The palette’s Kitty-graphics HUD (irix.kittyhud) and the wider terminal-pixel-graphics landscape it sits in: Rust TUI Pixel Graphics: State of the Art (Mid-2026).

Semantics: role keys, and the per-mode override#

irix.semantics.Semantics (see Semantics: Canonical Semantic Role Map) is a second layer above the raw palette: instead of “which hue and shade”, it answers “what is this pixel for”. irix/data/semantics.yaml maps roles (comment.doc, ui.error, terminal.ansi-red, …) to palette addresses, distilled from the house Sublime scheme, kimi-code theme, and colors.py defaults. Two classifiers project foreign theme vocabularies onto that canon:

  • Semantics.classify_scope – TextMate scopes (comment.line.double-slash) to role keys, dot-boundary prefix matching, most-specific-first.

  • Semantics.classify_workbench – VSCode workbench keys (statusBar.errorBackground) to role keys, via exact overrides, an ANSI-suffix table, a bracket-rotation formula, then substring patterns.

Semantics.address(role, mode) resolves a role with prefix fallback (keyword.control.flow → keyword.control → keyword) and an optional per-mode override. Most role addresses are a plain 'hue-shade' string that resolves identically under both Palette.dark() and Palette.light(); a role can instead carry {dark: ..., light: ...} when the same rung is legible on a dark surface but not a light one. This is the same RoleAddress shape irix.targets.Anchors uses for target anchors – one override mechanism, two tables.

Why the override exists. Radix’s sign1/sign2 rung (steps 9/10) is calibrated as the “solid/brand” UI-fill step against a dark surface – buttons and accents, not body text. Measured against the light editor background (gray-sheet2, #F9F9F9), several roles fell below the WCAG AA contrast floor. Dark mode was left untouched by design; only the light half of twelve roles was moved one or two rungs deeper on the same hue (sign1/sign2 → text1 or text2) until it cleared its floor. accent/markup.heading (amber-sign1 → amber-text1, 1.50:1 → 4.38:1) is the largest single jump; most others land in the 3–4:1 → 4.4–5.7:1 range. See irix/data/semantics.yaml’s header for the full before/after table and the floor rationale (body text ≥ 4.5:1, large/UI-accent ≥ 3.0:1).

The legibility ledger#

tests/test_legibility.py is the enforcement half of that fix, and it is a ledger, not a one-shot check: KNOWN_SHORTFALLS is a frozen {mode: {role: ratio}} table pinning every text-ish role’s measured contrast against its floor, for both modes. TestLegibilityLedger.test_shortfalls_match_known_ledger fails loudly the moment any role’s ratio drifts from the pinned value in either direction – a regression or an untracked improvement both require a deliberate ledger update, so nothing moves silently. Two narrower tests isolate the actual regression guard: test_no_fixed_role_in_light_ledger asserts none of this lane’s twelve fixed roles reappear as a light-mode shortfall, and test_fixed_role_clears_floor_in_light re-measures each of them directly. Roles the ledger lists as shortfalls but that this lane did not touch are deliberately out of scope, not silently accepted – the ledger records them so a future lane inherits a known, bounded list instead of rediscovering the gap by eye.

The targets engine#

irix.targets (see targets: The Deploy-Target Registry) is the seam between the palette/semantics model above and every artifact Irix produces – kitty configs, VSCode themes, a Rust source file, CSS custom properties, and a dozen others.

Policy vs. mechanism#

Every application is a Target: a small policy object (name, family, and a render(mode) that returns {relpath: content}). The mechanism – iterate both modes, write out/, diff against disk, copy to local config – lives exactly once, in irix.cli and irix.deploy, not per target. Anchors (irix/data/anchors/*.yaml, one file per target family) is the companion policy table: it records which palette hue anchors which of a target’s roles (kitty’s neutral is slate, alacritty’s is sage, …) as data, so regeneration preserves an application’s established look instead of a squad re-deciding it from scratch. Target.anchor(role, mode) is the one path from a target’s own role vocabulary down to a resolved hex: Anchors.load().address(...) (with the same per-mode override shape Semantics uses) into resolve_strict(palette_for(mode), ...), which raises rather than ever embedding an empty color.

The parity render contract#

Target.render takes an explicit Mode – there is no “render, and dark is assumed” path. The engine (irix.cli.cmd_generate/cmd_check, irix.deploy.Deployer) always calls it once per member of MODES = ('dark', 'light'), so a dark-only target is unrepresentable rather than merely a lint warning. tests/test_parity.py is the structural suite that makes the contract mean something instead of being an aspiration: registry parity (both modes render non-empty, and normally different, content), disk parity (every rendered relpath exists under out/), anchors/semantics integrity (every table entry resolves under both modes without raising), and palette-level contrast invariants, independent of which role happens to route through a rung.

A small number of targets are sanctioned single-artifact, dual-mode exceptions to “different content per mode”: the pymodule/cssvars targets (see targets.portable: Portable and Creative Deploy Targets) each emit one file that already carries both palettes as parallel constants, so render('dark') and render('light') legitimately return byte-identical content. test_parity.py found a third case (editors.VimTarget) while being written, and records it as a correction to the lane’s own brief rather than silently special-casing it – the kind of thing this ledger-vs-brief-drift the parity suite exists to catch in the first place.

irix check: the freshness loop#

irix check [TARGET...] (irix.cli.cmd_check, see irix: The Deploy-Engine Console Script) renders every selected target in memory and diffs the result against out/ on disk, printing one OK/DRIFT line per artifact and exiting 1 on any drift. This is the freshness gate CI runs: out/ is committed, generated package data, and this command is what stops a hand-edited artifact – or a palette/anchor change nobody regenerated from – from shipping quietly out of sync. irix generate is the write side of the same render path; check is the read-only verification side, sharing every line of rendering logic with it.

The four-copies problem#

Before this wave, the Irix palette’s canonical values existed as up to four independently hand-maintained copies that could – and did – drift apart: the irix-{dark,light}.yaml source data itself, a hand-duplicated Rust table in a sibling crate (ten of twenty hues, no light mode at all), a hand-authored CSS custom-properties file, and a hand-authored portable Python module. tests/test_parity.py’s docstring names the concrete failure modes this produced: unsynced palette copies, an Ayu-Light-contaminated file masquerading as Irix (vim’s light half), and role-level contrast shortfalls nobody had measured. All copies are now generated from the single Palette/Color source (cssvars, pymodule). The cssvars target’s own root-level counterpart – the still-hand-maintained irix-theme.css that predates this wave – gets the same treatment: tests/test_targets_portable.py’s TestCssvarsTarget 480-value-agreement gate checks every raw step var across the palette YAMLs, irix-theme.css, and the generated web/irix-vars.css in one assertion. Either way, “the palette drifted and nobody noticed” is now a test failure, not a discovery six months later.

Where each piece lives#

Layer

Module

Docs

Palette data

irix.Palette, irix.Color

Palette: Loading and Resolution, Color: Indexed Palette Addressing

Semantic roles

irix.semantics

Semantics: Canonical Semantic Role Map

Foreign theme adapters

irix.schemes

schemes: Theme File Format Adapters

Theme adapt/generate/check

irix.themecode

themecode: Adapt, Generate, and Check Editor Themes

Target registry (policy)

irix.targets

targets: The Deploy-Target Registry

Target families (policy)

irix.targets.{editors,portable,shell,terminals}

targets.editors: VSCode, kimi, Sublime, and Vim Targets, targets.portable: Portable and Creative Deploy Targets, targets.shell: Shell-Tooling Targets, targets.terminals: Terminal-Emulator Targets

Generation/check/deploy CLI (mechanism)

irix.cli, irix.deploy

irix: The Deploy-Engine Console Script, Deployer: Registry-Driven Artifact Deployment

Shared terminal primitives

irix.term

term: Shared Terminal Primitives