Skip to main content

Writing algorithms

Author your own algorithm

An algorithm is the durable, reusable asset behind every theme: a named recipe that maps a few anchor colors and knobs into a full, internally consistent token register. It is a xript (opens in a new tab) mod, so the toolchain, the capability model, and the zero-authority sandbox come free. This page covers what a mod looks like, the two ways to write one, and how to prove it before it ships.

What an algorithm is

The engine (@xtyle/core) is mechanism: the open token register, the dependency graph, the contrast math, the coverage contract, emit. It holds no opinion about how a ramp should look. The algorithm is the opinion: how this one derives. A theme is just one materialized invocation of an algorithm, so the algorithm is the part worth writing once and reusing.

Mechanism stays in the engine
If a derivation choice would only ever make sense for one algorithm, it belongs in the algorithm, not in @xtyle/core. That split is what lets many algorithms share one engine.

The built-in set

Six algorithms ship blessed. They are ordinary mods, the same shape yours takes, and a good place to read taste off working examples.

idposture
xtyle-defaultthe neutral default; the shared taste with nothing turned up
xtyle-quietrestrained; muted chroma, soft elevation
xtyle-loudvibrant; boosted chroma and punchier depth
xtyle-hchigh contrast; an AAA floor for accessibility
nxi-niteDay/Night; a time-of-day pass layered on the base

Anatomy of a mod

A mod is a directory: a manifest that declares it, and the code it runs. The manifest names the mod, the color-math capability that unlocks the cuti color primitives, and the script entry. The four exports the host calls (graph, traced, manifest, invariants) are registered for you by the authoring helper, so you never touch the raw plumbing.

algorithms/sunrise/
├─ mod-manifest.json   # declares the mod: name, capabilities, entry
└─ src/
   ├─ preset.ts        # the taste, as pure data
   └─ mod.ts           # defineXtyleAlgorithm(spec)
{
  "$schema": "https://xript.dev/schema/mod-manifest/v0.7.json",
  "xript": "0.7",
  "name": "sunrise",
  "version": "0.1.0",
  "title": "Sunrise",
  "family": "xtyle",
  "capabilities": ["color-math"],
  "entry": { "script": "src/mod.js", "format": "script" }
}

Tier 1: a family preset

The fast path. defineXtyleAlgorithm takes a taste vector and runs the shared xtyle derivation for everything you do not override. xtyle-default's entire definition is { id: "xtyle-default" }; from there you turn knobs up or down.

// src/mod.ts
import { defineXtyleAlgorithm } from "@xtyle/core/authoring";

defineXtyleAlgorithm({
  id: "sunrise",
  anchors: { bg: "#1a1410", accent: "#ff9e5e" },
  vibrancy: 0.7,
  chroma: { accent: 1.4, palette: 1.3 },
  accentStrategy: "step",   // spread accent-2/3/4 evenly, not as flanks + complement
});

Every field is optional but id; the rest fall through to the shared defaults. accentStrategy is the one taste that reshapes the accent family itself, and it is also a knob — so what you set here is the algorithm's default posture, not a lock a theme can't move. fan (the default) flanks the accent with 2/3 and complements it with 4; step walks all four evenly around the hue circle; shade holds one hue and steps its lightness; and duo takes two brand colors — --accent and --accent-2 are both inputs, and 3/4 become their shades, placed against the pair's mean lightness so the two ramps read as one system.

fan 2 and 3 flank the accent; 4 is its complement
accent base hue #6c5cff
accent-2 -45° #0086bc
accent-3 +45° #bd34c4
accent-4 -180° #8c7d00
step all four step evenly around the wheel
accent base hue #6c5cff
accent-2 +90° #e30859
accent-3 -180° #8c7d00
accent-4 -90° #008f8a
shade one hue, stepped in lightness: a tint and two deeper shades
accent L 59 #6c5cff
accent-2 L 71 #9092ff
accent-3 L 47 #4f30d5
accent-4 L 35 #3400a0
duo two brand colors in, both shaded against the pair's mean lightness
accent brand #6c5cff
accent-2 brand #ff8a3d
accent-3 L 83 #bcc1ff
accent-4 L 83 #ffb387

All four derive off the same #6c5cff accent (degrees are the hue step off it; L is the OKLCH lightness), and duo takes a second brand anchor of #ff8a3d. fan keeps the family tight and adds one true opposite; step trades that cohesion for even separation; shade drops hue harmony for one brand color in four depths, so even a near-gray accent stays legible where a hue turn would collapse; duo is the one to reach for when the brand simply is two colors, and neither should be derived from the other.

fieldtypewhat it sets
idstringthe algorithm's id, how it loads and derives
anchors{ bg, fg, accent }baked default anchors; any omitted one derives from the others
vibrancy0–1how saturated the derivation runs before the knob overrides it
chroma{ accent, status, palette, neutral, accentTint }per-family chroma multipliers over the shared derivation
contrast{ floor, textOnFill }the AA/AAA floors the derivation must clear
elevation{ strength, alphaBoost }shadow weight and overlay opacity
accentStrategy"fan" | "step" | "shade" | "duo"the accent family this taste ships with — the default for the accentStrategy knob, which a theme can still override
knobsstring[]the knobs this algorithm accepts (defaults to the shared set)
knobSpecsKnobSpec[]control domains for any knob beyond the shared set, so a knob of your own self-renders in the builder
passes(preset, input) => Pass[]ordered passes for a staged derivation (single-pass if omitted)

Tier 2: from scratch

When you want to own the derivation, defineAlgorithm hands you the input and the cuti color primitives and asks for the token graph back: an array of nodes, each naming a token, its value, and the tokens it derives from. Declare what you produce and how it categorizes so the coverage contract and emitters know the shape.

import { defineAlgorithm } from "@xtyle/core/authoring";

defineAlgorithm({
  id: "mono",
  produces: ["--bg-0", "--fg-0", "--accent", "--accent-fg"],
  categories: { color: ["--bg-0", "--fg-0", "--accent", "--accent-fg"] },
  knobs: ["scheme"],
  derive(input, { cuti }) {
    const bg = input.anchors?.bg ?? "#101216";
    const accent = cuti.desaturate(input.anchors?.accent ?? "#8a8f98", 0.4);
    const inks = ["#ffffff", "#0a0a0a"];
    return [
      { name: "--bg-0", value: cuti.toHex(bg) },
      { name: "--fg-0", value: cuti.pickReadable(bg, inks), refs: ["--bg-0"] },
      { name: "--accent", value: cuti.toHex(accent) },
      { name: "--accent-fg", value: cuti.pickReadable(accent, inks), refs: ["--accent"] },
    ];
  },
});
One color implementation, shared
cuti (toOklch, mix, pickReadable, contrast, and the rest) is the same math the engine derives with, so a token your mod produces can't drift from one the engine produces.

Declaring knob controls

An algorithm's knobs names the dials it reads; knobSpecs declares their domain: the kind of control, its range or options, its default. Each one then self-renders in the theme builder. The shared knobs (scheme, vibrancy, contrastBand, density, cues, accentSplit, accentShiftStep, typeScale, radiusScale) carry their domains for free; declare a KnobSpec only for a knob of your own.

import { defineAlgorithm } from "@xtyle/core/authoring";

defineAlgorithm({
  id: "mono",
  produces: ["--bg-0", "--fg-0", "--accent"],
  categories: { color: ["--bg-0", "--fg-0", "--accent"] },
  knobs: ["scheme", "punch"],           // 'scheme' is shared; 'punch' is your own
  knobSpecs: [
    { name: "punch", kind: "range", label: "Punch", min: 0, max: 0.4, step: 0.02, default: 0.15 },
  ],
  derive(input, { cuti }) {
    const punch = typeof input.knobs?.punch === "number" ? input.knobs.punch : 0.15;
    const accent = cuti.saturate(input.anchors?.accent ?? "#8a8f98", punch);
    // ... derive the register, its accent chroma lifted by 'punch'
    return [/* nodes */];
  },
});

The builder renders the control straight from your declaration (a range slider, a select, or a text field), so a knob no one hardcoded a UI for still appears the moment your algorithm reads it. See it live on The Bench : pick an algorithm and its own knobs fill the right rail.

fieldtypewhat it sets
namestringthe knob name, matched against the algorithm's knobs
kind"select" | "range" | "text"the control the builder renders
min / max / stepnumberthe bounds of a range knob
options{ value, label? }[]the accepted values of a select knob
defaultstring | numberthe value the knob takes when first switched on
labelstringa default label hint; a consumer may localize it
groupstringsections controls in the builder; defaults to the name
unitstringa suffix intrinsic to the value, e.g. ° for a hue step
The algorithm owns the domain; the consumer owns cosmetics
What a knob accepts and how it's shaped (kind, range, options) is the algorithm's declaration. A consumer layers only cosmetics on top (a localized label, digit precision), so the two never re-litigate who decides a knob's valid values.

Deriving in passes

An algorithm that derives in stages lists ordered passes. Each is a register → register transform; the first receives the anchors and knobs, and later ones reshape what came before. A family algorithm typically starts from settlePass (the shared derivation) and layers on top. nxi-nite is the worked example, shifting the whole palette by time of day after the base settles.

import { defineXtyleAlgorithm, settlePass } from "@xtyle/core/authoring";

defineXtyleAlgorithm({
  id: "day-cycle",
  passes: (preset, input) => [
    settlePass(preset, input),   // the shared xtyle derivation
    timeOfDayShift(input),       // a register → register transform
  ],
});

Prove it

The gauntlet runs your algorithm against extreme and random anchor sets and checks every invariant it declares: contrast floors held, no token collapsed, nothing parsed to NaN. Run it hosted to prove the sandboxed mod that actually ships. The coverage check confirms a component's consumed tokens are all in what you produce, and xtyle audit grades a materialized theme against xtyle's canonical text/fill pairs at the WCAG floors: the register-level complement to the gauntlet's algorithm-level sweep, so you can read a specific theme's contrast per pair.

# prove the invariants across extremes + random anchor sets
npx xtyle gauntlet -a sunrise --mode hosted --depth full

# confirm a component's consumed tokens are all produced
npx xtyle coverage -a sunrise --consumed --bg-0,--fg-0,--accent

# grade a derived theme against the canonical WCAG text/fill pairs
npx xtyle audit -a sunrise

The gauntlet proves a theme is safe, not that it is good. For that, derive a real theme and look at it under the components on The Bench .

Load it

Build the mods to bundle your mod.ts into the self-contained mod.js the host runs, and the algorithm loads by id, the same path the built-ins take. From there it works everywhere the engine does: the CLI, the importable API, and the browser.

npm run build:mods            # bundles src/mod.ts → src/mod.js
npx xtyle derive -a sunrise    # loads by id, derives a theme

Questions or contributions live on GitHub (opens in a new tab) .