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
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.
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.
| id | posture |
|---|---|
xtyle-default | the neutral default; the shared taste with nothing turned up |
xtyle-quiet | restrained; muted chroma, soft elevation |
xtyle-loud | vibrant; boosted chroma and punchier depth |
xtyle-hc | high contrast; an AAA floor for accessibility |
nxi-nite | Day/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 step all four step evenly around the wheel shade one hue, stepped in lightness: a tint and two deeper shades duo two brand colors in, both shaded against the pair's mean lightness 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.
| field | type | what it sets |
|---|---|---|
id | string | the algorithm's id, how it loads and derives |
anchors | { bg, fg, accent } | baked default anchors; any omitted one derives from the others |
vibrancy | 0–1 | how 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 |
knobs | string[] | the knobs this algorithm accepts (defaults to the shared set) |
knobSpecs | KnobSpec[] | 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"] },
];
},
});
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
| field | type | what it sets |
|---|---|---|
name | string | the knob name, matched against the algorithm's knobs |
kind | "select" | "range" | "text" | the control the builder renders |
min / max / step | number | the bounds of a range knob |
options | { value, label? }[] | the accepted values of a select knob |
default | string | number | the value the knob takes when first switched on |
label | string | a default label hint; a consumer may localize it |
group | string | sections controls in the builder; defaults to the name |
unit | string | a suffix intrinsic to the value, e.g. ° for a hue step |
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
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