iridis

iridis

A chromatic engine that resolves any seeds — or any image — into a full, contrast-enforced, OKLCH-native palette. Every pixel here is engine.run().

Palette

Create a palette by uploading an image, using a sample, or manually adding hues. Adjust extraction parameters to re-cluster and regenerate.

Drop an image or click to browse
PNG, JPG, WEBP — extracts dominant hues based on your schema
Schema & Compliance

Role schema

How many roles to resolve — iridis-4 is the minimal set, iridis-32 resolves the full token surface this site renders.

48121632

Color Space

The color space used when exporting CSS variables. Display P3 allows for much wider gamut colors on compatible displays.

Compliance strictness

APCA is the modern perceptual contrast algorithm (target Lc).

AAAAAAPCA
Auto-correct CVD failuresAlso always-on — adjusts the palette itself, same as the level above.
Simulate CVD vision

Changes how this page looks to you — it does not touch the palette.

Derivation Algorithms
Pipeline
Pipeline

Expand a stage — the description underneath is that task's own manifest, not marketing copy. Optional stages are automatically switched on or off depending on the compliance strictness setting.

required

required

required

required

required

disabled

disabled

enabled

required

required

Roles table
Roles table0 roles

Same roles as the grid, laid out for sorting — click a header.

No data

CVD vision
CVD vision

Color vision deficiency (CVD) checking runs on every palette, always — see enforce:cvdSimulate in the Pipeline card. This card is where you actually look: pick any combination of conditions below to preview the current palette the way that vision would see it. This never modifies the palette — for that, see "Auto-correct CVD failures" in the Contrast target card above the carousel.

Simulate CVD vision
Protanopia

The L-cone (long-wavelength, red-sensitive) is absent. Reds appear darker and can be confused with black, greens, or browns.

~1% of men

Deuteranopia

The M-cone (medium-wavelength, green-sensitive) is absent — the most common dichromacy. Reds and greens both shift toward a shared yellowish-brown.

~1% of men

Tritanopia

The S-cone (short-wavelength, blue-sensitive) is absent. Rare, and unlike the other two, affects men and women about equally. Blues and greens, or yellows and violets, become hard to tell apart.

<0.01% of people

Achromatopsia

Complete absence of color vision (rod monochromacy) — everything resolves to luminance only, the way a black-and-white photo does.

very rare

Pick any combination above — real CVD isn't always one condition, and previewing multiple at once chains their filters (see CvdPreviewOverlay.vue).

Roles
Resolved roles0 roles
Components
Click to fire a real UToast
Dismissible alerts

No alerts yet — add one.

APCA compliance0%
Primary scale
Spectrum

The full 50→950 ramp per alias.

Primary
500
Secondary
500
Success
500
Warning
500
Error
500
Info
500
Neutral
500
Motion
Motion

Drag the duration — every color transition on this page, not just the swatches below, runs on this same clock.

pulse-glow · 3s
carousel arrows, active dot
float · 7s
hero logo, floating orbs
spin · 26s
ambient background accent
sheen · 4s
every glass panel’s top edge
orbit · 2.2-3.8s
three roles, three independent rings
sonar · 2.4s
success/warning/error/primary in sequence
radar · 2.6s
primary bleeding into secondary, one sweep
chroma · 4s
the accent hue cycling the full wheel
Spaces
Color spaces
Schema
Schema tree

Each tier adds roles over the last. "resolved" competes for a seed by default; "← source" is hue-derived — pin either one in Palette to override it.

Clamps

This shows the engine's hidden "hand" by revealing how every role was selected from the provided seeds. You can see the OKLCH distance for each candidate seed, whether a role had to be synthesized, and where input seeds were forcefully clamped to satisfy lightness, chroma, or semantic hue envelopes.

No roles resolved yet.
What you came here for

Every output format with a real emit plugin — one palette, 13 targets.

:root {
  --c-background: #060102;
  --c-text: #ecdadf;
  --c-brand: #f24b8e;
  --c-surface: #100307;
  --c-bg-soft: #1a050d;
  --c-divider: #877077;
  --c-on-brand: #040202;
  --c-success: #97b026;
  --c-warning: #dda818;
  --c-syntax-keyword: #fc7faa;
  --c-syntax-string: #a8c523;
  --c-syntax-number: #fd9b3c;
  --c-syntax-function: #0ac9b1;
  --c-syntax-type: #1acae2;
  --c-text-strong: #f9ecf0;
  --c-text-subtle: #a49398;
  --c-link: #f35cbc;
  --c-link-hover: #fb93d0;
  --c-focus-ring: #fa4891;
  --c-border: #352328;
  --c-border-strong: #573c44;
  --c-code-bg: #0b0305;
  --c-info: #a25ff9;
  --c-accent-alt: #1aae54;
  --c-syntax-tag: #4db8fc;
  --c-syntax-attribute: #8fa9fd;
  --c-syntax-operator: #c1a0fb;
  --c-syntax-class: #fa9484;
}

@supports (color: color(display-p3 0 0 0)) {
  :root {
    --c-success: color(display-p3 0.6046 0.6997 0.0000);
    --c-warning: color(display-p3 0.8947 0.6412 0.0000);
    --c-syntax-keyword: color(display-p3 0.9955 0.4533 0.6567);
    --c-syntax-string: color(display-p3 0.6781 0.7742 0.1590);
    --c-syntax-number: color(display-p3 1.0000 0.5797 0.0000);
    --c-syntax-function: color(display-p3 0.0958 0.8045 0.7018);
    --c-syntax-type: color(display-p3 0.2237 0.7942 0.9098);
    --c-link-hover: color(display-p3 1.0000 0.4854 0.8451);
    --c-focus-ring: color(display-p3 0.9647 0.2252 0.5594);
    --c-accent-alt: color(display-p3 0.2640 0.6859 0.3320);
    --c-syntax-tag: color(display-p3 0.2184 0.7155 1.0000);
    --c-syntax-attribute: color(display-p3 0.5380 0.6403 1.0000);
    --c-syntax-operator: color(display-p3 0.7445 0.6119 1.0000);
    --c-syntax-class: color(display-p3 1.0000 0.5126 0.4212);
  }
}

@media (forced-colors: active) {
  :root {
    --c-background: Canvas;
    --c-text: CanvasText;
    --c-brand: Highlight;
    --c-surface: CanvasText;
    --c-bg-soft: CanvasText;
    --c-divider: CanvasText;
    --c-on-brand: CanvasText;
    --c-success: CanvasText;
    --c-warning: CanvasText;
    --c-syntax-keyword: CanvasText;
    --c-syntax-string: CanvasText;
    --c-syntax-number: CanvasText;
    --c-syntax-function: CanvasText;
    --c-syntax-type: CanvasText;
    --c-text-strong: CanvasText;
    --c-text-subtle: CanvasText;
    --c-link: CanvasText;
    --c-link-hover: CanvasText;
    --c-focus-ring: CanvasText;
    --c-border: CanvasText;
    --c-border-strong: CanvasText;
    --c-code-bg: CanvasText;
    --c-info: CanvasText;
    --c-accent-alt: CanvasText;
    --c-syntax-tag: CanvasText;
    --c-syntax-attribute: CanvasText;
    --c-syntax-operator: CanvasText;
    --c-syntax-class: CanvasText;
  }
}

Highlighted by Shiki, colored by this site's own VS Code theme output — not a static theme.

1. What is Iridis

iridis is a chromatic pipeline for dynamic palette derivation. You give it seed colors (hex, RGB, OKLCH, etc.). It runs them through a registered sequence of tasks—intake, role resolution, contrast enforcement, variant derivation, and emission—and returns a role-resolved palette plus any consumer-shaped outputs you requested (CSS variables, Tailwind, Shadcn, MUI, Capacitor, etc.).

The core (@studnicky/iridis) ships with zero runtime dependencies. Each output target is a separate plugin package.

Installation

Install the core package, plus any output plugins you need.

# Core only
npm install @studnicky/iridis

# W3C contrast checking + Stylesheet + Shadcn UI outputs
npm install @studnicky/iridis \
  @studnicky/iridis-contrast \
  @studnicky/iridis-stylesheet \
  @studnicky/iridis-shadcn

The simplest call (quickPalette)

For basic use cases, you don't even need to configure an engine. Use the quickPalette helper:

import { quickPalette } from '@studnicky/iridis';

const palette = await quickPalette(['#7c3aed', '#06b6d4'], 'dark');
// → { background: '#07061a', foreground: '#f0f0ff', accent: '#7c3aed', muted: '#7e7e9a' }

One import, one call. No schema to define, no pipeline to declare. The framing argument ('dark' or 'light') picks the clamp envelopes, and everything else uses sensible defaults.

2. The Four Stages

Every iridis pipeline passes through four conceptual stages, even though the task names and execution order are fully controlled by you:

intake resolve enforce emit

The Data Flow

3. Adopting Iridis In An Existing App

Integrating Iridis into an existing application shouldn't require rewriting all of your CSS or switching out your Tailwind classes. Consumers can adopt Iridis effortlessly by presenting their existing color tokens and mapping them directly to the variables Iridis outputs.

Your goal is to configure Iridis to target the CSS Custom Properties (variables) your app already uses.

The Strategy: CSS Variable Mapping

Most modern UI frameworks (like Tailwind CSS, Nuxt UI, Shadcn, MUI, or Bootstrap) rely on CSS variables under the hood. If your app already uses classes like text-primary or bg-surface, those are typically backed by a variable like --color-primary or --surface.

Instead of replacing text-primary with something else, you simply tell Iridis to overwrite --color-primary.

There are two primary ways to do this:

1. Direct Alignment (Emit Custom Keys)

If your existing application expects variables named --brand-main, --bg-base, and --text-body, you can define an Iridis Role Schema that exactly matches those names. Iridis's emit:cssVars (from @studnicky/iridis-stylesheet) will generate the CSS using those exact keys.

import { Engine } from '@studnicky/iridis';

const schema = {
  roles: [
    { name: 'brand-main', /* ... */ },
    { name: 'bg-base', /* ... */ },
    { name: 'text-body', /* ... */ }
  ]
};

// ... engine setup ...
const state = await engine.run({
  colors: ['#00FF00', '#FF0000'],
  roles: schema
});

// Outputs:
// :root {
//   --brand-main: #...;
//   --bg-base: #...;
//   --text-body: #...;
// }

When you inject this generated CSS into your <head>, your existing CSS and components will instantly react to the new scheme without a single code change.

2. The { key: val } Alias Map

If you prefer to use the standard Iridis schema (which handles complex contrast logic seamlessly for roles like background, brand, text, etc.) but need it to interface with your existing variables, you can simply write a CSS mapping block.

Generate the standard Iridis CSS variables, and then map your app's variables to point to them:

/* Your app's existing theme file */
:root {
  /* Map your existing tokens to Iridis's outputs */
  --color-primary: var(--iridis-brand);
  --color-primary-hover: var(--iridis-brand-600);

  --bg-surface: var(--iridis-background);
  --text-main: var(--iridis-text);

  --border-color: var(--iridis-muted);
}

With this single mapping block, the entire Iridis pipeline (including contrast checking and synthesis) powers your existing UI, guaranteeing that your application remains fully responsive to any palette change.

4. Long-form Engine API

quickPalette covers the simple case. When you need custom schemas, explicit contrast checking, or plugin emitters, construct the Engine directly.

import { Engine, coreTasks }  from '@studnicky/iridis';
import { stylesheetPlugin }   from '@studnicky/iridis-stylesheet';
import { shadcnPlugin }       from '@studnicky/iridis-shadcn';

const engine = new Engine();
// Register core tasks
for (const task of coreTasks) engine.tasks.register(task);

// Adopt output plugins
engine.adopt(stylesheetPlugin);
engine.adopt(shadcnPlugin);

// Declare the execution sequence
engine.pipeline([
  'intake:any',
  'expand:family',
  'resolve:roles',
  'enforce:contrast',
  'derive:variant',
  'emit:cssVars',
  'emit:shadcnVars'
]);

// Run the pipeline
const state = await engine.run({
  colors: ['#8B5CF6'],
  roles: yourRoleSchema,
  contrast: { level: 'AA' },
});

// The results are available in state.outputs
console.log(state.outputs['stylesheet:cssVars']);

Two ways to run

iridis works as an NPM library AND as a CLI tool.

As a library

Construct new Engine(), register the core tasks (coreTasks), adopt() the plugins you want, declare your pipeline() order, and call run(input). Math primitives are independent singletons; import any of them directly from @studnicky/iridis when you need to call colour math outside the pipeline.

As a CLI

Install @studnicky/iridis-cli, write a JSON config with enable* flags, and run:

iridis ./palette.config.json

Same engine, same plugins. The CLI dynamically imports only the plugins whose enable* flag is true. Use it in build scripts, CI, or one-off generation jobs. See CLI Usage for the full config shape.

5. Recipe: Vue + Capacitor Per-Category Palettes

A worked example of the mapping strategy: examples/vue-capacitor/categoryColorService.ts is a service that takes a category name and a seed hex color, runs the Iridis pipeline, writes scoped CSS custom properties to the document, and returns Capacitor StatusBar parameters for native chrome.

CategoryColorService is a singleton that owns a single Engine instance configured at construction time. The engine is set up once; individual calls to apply(category, seed) just invoke engine.run() with a new input.

private constructor() {
  this.engine = new Engine();
  for (const task of coreTasks) {
    this.engine.tasks.register(task);
  }
  this.engine.adopt(contrastPlugin);
  this.engine.adopt(stylesheetPlugin);
  this.engine.adopt(capacitorPlugin);
  /* Example wires every compliance check the engine exposes:
     WCAG 2.1 AA + AAA, APCA Lc targets, and CVD simulation against
     protanopia + deuteranopia + tritanopia + achromatopsia. Real
     consumers opt in/out via their own pipeline; the example
     demonstrates the maximal-correctness configuration. */
  this.engine.pipeline([
    'intake:any',
    'resolve:roles',
    'expand:family',
    'enforce:wcagAA',
    'enforce:wcagAAA',
    'enforce:apca',
    'enforce:cvdSimulate',
    'derive:variant',
    'emit:cssVars',
    'emit:capacitorStatusBar',
    'emit:capacitorTheme',
  ]);
}

The private constructor + static shared() pattern wires the engine once. Each service owns its own Engine instance and therefore its own isolated TaskRegistry, which matters when multiple services in the same application need different pipeline configurations.

From seed to palette

Using seed #8B5CF6 (a mid-purple, high chroma) against categoryW3cRoleSchema: intake:any parses the hex and converts it to OKLCH (approximately L=0.62, C=0.27, H=293). resolve:roles assigns it to the accent role; canvas, surface, and text receive the same seed as their only candidate, but their lightnessRange constraints push them to their range centers during expand:family's second pass. enforce:wcagAA then checks all four contrast pairs in the schema and nudges foreground roles until each pair meets 4.5:1 (text) or 3.0:1 (border).

emit:cssVars writes state.outputs['stylesheet:cssVars'] with three shapes: full (a single CSS string with all custom properties), scopedBlock (a scoped [data-category="music"] { ... } block), and map (a Record<string, string> of property name to value). emit:capacitorStatusBar writes state.outputs['capacitor:statusBar'], and emit:capacitorTheme writes state.outputs['capacitor:theme'].

Applying CSS variables dynamically

async apply(category: string, seed: string): Promise<{
  readonly cssVars:   CssVarsOutputInterfaceType;
  readonly statusBar: StatusBarOutputInterface;
}> {
  const state = await this.engine.run({
    'colors':   [seed],
    'roles':    categoryW3cRoleSchema,
    'contrast': { 'level': 'AA', 'algorithm': 'wcag21' },
    'metadata': {
      'category':     category,
      'cssVarPrefix': '--c-',
      'scopeAttr':    'data-category',
      'scopePrefix':  'category',
      'themeName':    category,
    },
  });
  const cssVars   = state.outputs['stylesheet:cssVars'] as CssVarsOutputInterfaceType;
  const capacitor = state.outputs['capacitor:statusBar'] as StatusBarOutputInterface;
  const sheetId   = `ce-${category}-styles`;
  let   sheet     = document.getElementById(sheetId) as HTMLStyleElement | null;

  if (!sheet) {
    sheet = document.createElement('style');
    sheet.id = sheetId;
    document.head.appendChild(sheet);
  }
  sheet.textContent = `[data-category="${category}"] {\n${
    Object.entries(cssVars.map).map(([k, v]) => `  ${k}: ${v};`).join('\n')
  }\n}`;

  return { 'cssVars': cssVars, 'statusBar': capacitor };
}

Any component that sets data-category="music" on its root element automatically picks up the derived palette via CSS custom property inheritance.

Vue 3 SFC integration

<template>
  <div data-category="music" class="music-category-view">
    <slot />
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { categoryColorService } from './categoryColorService.ts';

onMounted(async () => {
  await categoryColorService.apply('music', '#8B5CF6');
});
</script>
// Re-skin when the user switches category
async function onCategoryChange(category: string, seed: string) {
  const { statusBar } = await categoryColorService.apply(category, seed);
  await StatusBar.setBackgroundColor({ color: statusBar.backgroundColor });
  await StatusBar.setStyle({ style: statusBar.style === 'DARK' ? Style.Dark : Style.Light });
}

The emit:capacitorStatusBar task writes state.outputs['capacitor:statusBar'].backgroundColor (the resolved surface/topBar role hex) and .style ('DARK' or 'LIGHT' depending on background luminance) — pass these directly to the Capacitor StatusBar plugin.

In v1, iridis re-derives one category palette per call; calling apply() for different categories in sequence is supported, and multiple categories can coexist in the DOM simultaneously as long as they use distinct data-category values. Full living-color animation (smooth palette morphing between categories) is a v2 concern — see the Roadmap section of the architecture page.

6. Plugin Ecosystem

A plugin is any object that satisfies PluginInterface by providing a collection of tasks.

interface PluginInterface {
  readonly name:    string;
  readonly version: string;
  tasks(): readonly TaskInterface[];
}

When you call engine.adopt(plugin), iridis registers all of the plugin's tasks into the engine's TaskRegistry in one call.

Official Plugins

iridis ships with an ecosystem of plugins designed to format the resolved palette into consumer-ready code. Each is published as a separate @studnicky/iridis-* package:

  • @studnicky/iridis-stylesheet: Emits generic CSS custom properties (--c-primary: #...).
  • @studnicky/iridis-tailwind: Emits a Tailwind CSS tailwind.config.js or theme() compatible object.
  • @studnicky/iridis-shadcn: Emits CSS variables specifically structured for shadcn/ui integration.
  • @studnicky/iridis-mui: Emits a Material UI createTheme() compatible palette object.
  • @studnicky/iridis-chakra: Emits Chakra UI color tokens for extendTheme().
  • @studnicky/iridis-panda: Emits Panda CSS token configuration.
  • @studnicky/iridis-vscode: Derives a full VS Code theme JSON (tokenColors, semanticTokenColors, and workbench UI elements).
  • @studnicky/iridis-capacitor: Emits native status bar colors, splash screen config, and Android theme.xml for Capacitor mobile apps.
  • @studnicky/iridis-rdf: Emits the palette as an RDF/OWL semantic graph.

7. CLI Usage

You don't have to write Node.js scripts to use iridis. The @studnicky/iridis-cli package allows you to run the engine directly from your terminal using a JSON configuration file.

npm install -g @studnicky/iridis-cli

iridis ./palette.config.json

The CLI dynamically imports and runs the plugins defined in your configuration file. This makes it perfect for integrating into CI/CD pipelines, build scripts, or code generation workflows.

Install

npm install --save-dev @studnicky/iridis-cli

The CLI package declares the core engine as a peer dependency; install the plugins you need alongside it. Once installed, the iridis binary is available as an npm script command:

npx iridis ./palette.config.json

Config file shape

The config is a plain JSON file validated against CliConfigSchema (packages/cli/src/CliConfigSchema.ts). Three top-level fields are required: input, pipeline, and output.

{
  "input": {
    "colors":   ["#8B5CF6"],
    "contrast": { "level": "AA", "algorithm": "wcag21" },
    "roles":    { /* RoleSchemaInterface inline */ },
    "metadata": { "cssVarPrefix": "--c-" }
  },
  "enableContrast":   true,
  "enableStylesheet": true,
  "enableCapacitor":  false,
  "enableVscode":     false,
  "enableTailwind":   false,
  "enableImage":      false,
  "enableRdf":        false,
  "pipeline": [
    "intake:any",
    "resolve:roles",
    "expand:family",
    "enforce:wcagAA",
    "derive:variant",
    "emit:cssVars"
  ],
  "output": {
    "directory": "./out",
    "files": {
      "stylesheet:cssVars": "palette.css"
    }
  }
}

The enable* flags control which plugin packages are dynamically imported. Only flags set to true trigger an import, and the corresponding package must already be installed, the CLI does not install dependencies on your behalf. The pipeline array maps directly to engine.pipeline() in the library API; task names must be registered by either the core task set or one of the adopted plugins.

A full worked config that enables the Contrast and Capacitor plugins together:

{
  "input": {
    "colors": ["#8B5CF6"],
    "contrast": { "level": "AA", "algorithm": "wcag21" },
    "metadata": {
      "category":     "music",
      "cssVarPrefix": "--c-",
      "scopeAttr":    "data-category",
      "scopePrefix":  "category",
      "themeName":    "music"
    },
    "roles": {
      "name": "category-w3c",
      "description": "WCAG 2.1 AA role schema for category colour palettes",
      "roles": [
        {
          "name":           "canvas",
          "description":    "Page / card background",
          "intent":         "background",
          "required":       true,
          "lightnessRange": [0.92, 1.0]
        },
        {
          "name":           "surface",
          "description":    "Elevated surface (modal, sheet)",
          "intent":         "background",
          "required":       true,
          "lightnessRange": [0.86, 0.96]
        },
        {
          "name":           "accent",
          "description":    "Primary interactive / brand colour",
          "intent":         "accent",
          "required":       true
        },
        {
          "name":           "onAccent",
          "description":    "Text / icons placed on accent",
          "intent":         "text",
          "required":       true,
          "derivedFrom":    "accent",
          "lightnessRange": [0.98, 1.0]
        },
        {
          "name":           "border",
          "description":    "Dividers and focus rings",
          "intent":         "muted",
          "lightnessRange": [0.60, 0.80]
        },
        {
          "name":           "muted",
          "description":    "Secondary / de-emphasised text",
          "intent":         "muted",
          "lightnessRange": [0.45, 0.65]
        },
        {
          "name":           "text",
          "description":    "Primary body text",
          "intent":         "text",
          "required":       true,
          "lightnessRange": [0.10, 0.25]
        }
      ],
      "contrastPairs": [
        { "foreground": "text",     "background": "canvas",  "minRatio": 4.5, "algorithm": "wcag21" },
        { "foreground": "text",     "background": "surface", "minRatio": 4.5, "algorithm": "wcag21" },
        { "foreground": "onAccent", "background": "accent",  "minRatio": 4.5, "algorithm": "wcag21" },
        { "foreground": "border",   "background": "canvas",  "minRatio": 3.0, "algorithm": "wcag21" }
      ]
    }
  },
  "enableContrast":   true,
  "enableStylesheet": true,
  "enableCapacitor":  true,
  "pipeline": [
    "intake:any",
    "resolve:roles",
    "expand:family",
    "enforce:wcagAA",
    "derive:variant",
    "emit:cssVars",
    "emit:capacitorStatusBar",
    "emit:capacitorTheme"
  ],
  "output": {
    "directory": "./out",
    "files": {
      "stylesheet:cssVars":  "music.css",
      "capacitor:statusBar": "music-statusbar.json",
      "capacitor:theme":     "music-theme.json"
    }
  }
}

Wiring into a build pipeline

{
  "scripts": {
    "palette": "iridis ./palette.config.json",
    "build": "npm run palette && vite build"
  }
}
- name: Generate palette
  run: npx iridis ./palette.config.json

- name: Upload palette artifact
  uses: actions/upload-artifact@v4
  with:
    name: palette
    path: out/

The CLI exits with code 0 on success and non-zero on validation or pipeline errors. Standard output carries progress logs; standard error carries fatal messages, both are machine-readable in CI.

8. VS Code Theme Recipe

@studnicky/iridis-vscode derives a complete VS Code theme.json — workbench colors, semanticTokenColors, and tokenColors — from a 16-role palette. It's a five-task pipeline extension, not a single emit task, because a VS Code theme has more surface area than a CSS variable block: workbench chrome, editor syntax highlighting, and the newer semantic-token layer all need independently-derived color sets that then get assembled together.

Why a second engine pass

The plugin's tasks run after the core pipeline resolves roles and enforces contrast — vscode:expandTokens and vscode:applyModifiers are their own mini-pipeline stage that takes the resolved 16-role palette and expands it into VS Code's much larger color vocabulary (23 base tokens, then 253 semantic-token rules), re-checking contrast against the background role as it goes. This is the "second engine pass" referenced in MultiOutput.vue: the core pipeline produces a role palette sized for UI theming; the VS Code plugin then derives an editor-theme-sized palette from that role palette using its own derivation tables (DERIVATION_PARAMS, MODIFIER_TRANSFORMS, SCOPE_MAPPINGS) rather than reusing derive:variant.

Pipeline stages

StageTaskWhat it does
1vscode:expandTokensDerives 23 VS Code base token colors from the 16 resolved roles, using DERIVATION_PARAMS. Writes metadata['vscode:baseTokens'].
2vscode:applyModifiersApplies 10 color modifiers (from MODIFIER_TRANSFORMS) to the base tokens, producing 253 semantic-token rules, and re-enforces contrast against the background role for each. Writes metadata['vscode:semanticTokenRules'].
3aemit:vscodeSemanticRulesShapes the semantic-token rule map for editor.semanticTokenColorCustomizations.rules, using SCOPE_MAPPINGS and FONT_STYLES. Writes outputs['vscode:semanticTokenRules'].
3bemit:vscodeUiPaletteDerives 100+ workbench UI colors (editor.background, sideBar.foreground, etc.) from the 16-role palette, selecting light/dark variants by the resolved background luminance. Writes outputs['vscode:workbenchColors'].
4emit:vscodeThemeJsonCombines the semantic rules and workbench colors into one theme.json shape ({ name, type, colors, semanticTokenColors, tokenColors }). Requires both emit:vscodeSemanticRules and emit:vscodeUiPalette to have already run. Writes outputs['vscode:themeJson'].

Stages 3a and 3b are independent of each other and can run in either order (both depend only on stage 2's output), but stage 4 fails fast if either hasn't run yet.

Wide-gamut roles (records with displayP3 populated) serialize to the CSS Color 4 color(display-p3 r g b) form in every emitted theme slot; sRGB-only roles stay as #rrggbb. VS Code 1.85+ accepts either form in any color slot.

Producing a theme file

import { Engine, coreTasks } from '@studnicky/iridis';
import { vscodePlugin, vscodeRoleSchema16 } from '@studnicky/iridis-vscode';
import { writeFileSync } from 'node:fs';

const engine = new Engine();
for (const task of coreTasks) engine.tasks.register(task);
engine.adopt(vscodePlugin);

engine.pipeline([
  'intake:hex',
  'resolve:roles',
  'expand:family',
  'enforce:contrast',
  'derive:variant',
  'vscode:expandTokens',
  'vscode:applyModifiers',
  'emit:vscodeSemanticRules',
  'emit:vscodeUiPalette',
  'emit:vscodeThemeJson',
]);

const state = await engine.run({
  colors: ['#8B5CF6', '#EC4899', '#0d1117', '#e6edf3' /* ...16 seeds, one per vscodeRoleSchema16 role */],
  roles: vscodeRoleSchema16,
  metadata: { themeName: 'Iridis Dark' },
});

const themeJson = state.outputs['vscode:themeJson']!;
writeFileSync('themes/iridis-dark-color-theme.json', JSON.stringify(themeJson, null, 2));

vscodeRoleSchema16 is the canonical 16-role schema the plugin expects — pass 16 seed colors matching its roles, in order.

Loading it as a VS Code extension theme

The written file is a standard VS Code color theme contribution. Reference it from an extension's package.json:

{
  "name": "iridis-dark-theme",
  "contributes": {
    "themes": [
      {
        "label": "Iridis Dark",
        "uiTheme": "vs-dark",
        "path": "./themes/iridis-dark-color-theme.json"
      }
    ]
  }
}

For local testing without publishing an extension, symlink or copy the generated theme.json into an existing theme extension's themes/ directory, or load the workspace folder in VS Code's Extension Development Host (F5) with the package.json above.

9. Task Registry Reference

Every task registered by the core engine and by each official plugin, grouped by plugin. name is the pipeline identifier passed to engine.pipeline([...]); requires (where present) lists tasks that must already have run in the same pipeline.

Core (@studnicky/iridis)

TaskRequiresDescription
intake:anyRecommended default intake task. Dispatches each entry to the first matching format delegate.
intake:hexParses #RRGGBB, #RGB, and 8-digit hex strings.
intake:rgbParses {r,g,b,a?} in 0..1 or 0..255 (auto-detected).
intake:hslParses {h,s,l,a?}.
intake:oklchParses {l,c,h,a?} OKLCH.
intake:labParses {l,a,b} CIE Lab D65.
intake:p3Parses color(display-p3 r g b [/ alpha]) strings.
intake:namedParses CSS named color strings (e.g. rebeccapurple).
intake:imagePixelsParses ImageData or {data, width, height}, pushing non-transparent pixels.
clamp:oklchClamps each color's OKLCH lightness and chroma into role-defined (or default) ranges.
clamp:countReduces colors to maxColors (default 64) via weighted median-cut clustering when exceeded.
resolve:rolesAssigns colors to schema roles by hint match then OKLCH distance, then nudges into declared ranges.
expand:familyDerives missing roles with derivedFrom set, applying OKLCH deltas from the source role.
enforce:contrastChecks and nudges foreground role colors to meet minRatio for each contrast pair.
derive:variantProduces light/dark variants by transforming all roles.
emit:jsonWrites outputs['core:json'] with {colors, roles, variants} flattened to hex strings.

@studnicky/iridis-contrast

TaskRequiresDescription
enforce:wcagAAEnforces WCAG 2.1 AA contrast (4.5:1 normal text, 3:1 large/UI) on all role pairs.
enforce:wcagAAAEnforces WCAG 2.1 AAA contrast (7:1 normal text, 4.5:1 large/UI).
enforce:apcaEnforces APCA (WCAG 3 draft) Lc targets: 75 body text, 60 fluent text, 45 non-text UI.
enforce:cvdSimulateSimulates protanopia/deuteranopia/tritanopia/achromatopsia against published thresholds; advisory by default, auto-corrects when input.contrast.cvdCorrect is true.

@studnicky/iridis-image

TaskRequiresDescription
gallery:histogramQuantizes pixels into a 5-bit-per-channel histogram; emits weighted records keyed by bin centroid.
gallery:extractReduces input records to K dominant colors via median-cut (weighted) or deltaE-merge clustering.
gallery:assignRolesAssigns dominant colors to gallery roles: canvas, frame, accent, muted, text.
gallery:harmonizeShifts accent hue by 30° when its deltaE2000 distance to the frame color is under 10.

@studnicky/iridis-vscode

TaskRequiresDescription
vscode:expandTokensDerives 23 VS Code base token colors from the 16 resolved roles.
vscode:applyModifiersvscode:expandTokensApplies 10 color modifiers to base tokens, producing 253 semantic-token rules with re-enforced contrast.
emit:vscodeSemanticRulesvscode:applyModifiersShapes the semantic-token rule map for editor.semanticTokenColorCustomizations.rules.
emit:vscodeUiPaletteDerives 100+ VS Code workbench colors from the 16-role palette.
emit:vscodeThemeJsonemit:vscodeSemanticRules, emit:vscodeUiPaletteAssembles the complete theme.json: { name, type, colors, semanticTokenColors, tokenColors }.

@studnicky/iridis-stylesheet

TaskRequiresDescription
emit:cssVarsEmits CSS custom property blocks from resolved roles and variants.
emit:cssVarsScopedEmits per-category scoped CSS custom property blocks for Vue/Capacitor use cases.

@studnicky/iridis-tailwind

TaskRequiresDescription
emit:tailwindThemeEmits a Tailwind theme.colors object and config module from resolved roles.

@studnicky/iridis-shadcn

TaskRequiresDescription
emit:shadcnThemeEmits a shadcn/ui-compatible CSS custom-property theme (OKLCH, Tailwind v4 convention) from resolved roles.

@studnicky/iridis-mui

TaskRequiresDescription
emit:muiThemeEmits an MUI createTheme() palette object from resolved roles and shade variants.

@studnicky/iridis-chakra

TaskRequiresDescription
emit:chakraThemeEmits a Chakra UI extendTheme() color-token scale (100/500/900 per family) from resolved roles and dark/light variants.

@studnicky/iridis-panda

TaskRequiresDescription
emit:pandaThemeEmits Panda CSS token config and a UnoCSS-compatible theme object from the same resolved-role color map.

@studnicky/iridis-capacitor

TaskRequiresDescription
emit:capacitorStatusBarEmits Capacitor StatusBar configuration from the surface/topBar role.
emit:capacitorSplashScreenEmits Capacitor splash screen configuration from surface or an input-specified splashRole.
emit:capacitorThemeEmits a flat Capacitor theme map from resolved roles for native preference storage.
emit:androidThemeXmlEmits an Android themes.xml fragment for the Capacitor splash screen and status bar.

@studnicky/iridis-rdf

TaskRequiresDescription
reason:annotateAnnotates the palette with RDF triples via an n3 Store.
reason:serializeSerializes rdf:reasoningGraph to Turtle / TriG / N-Quads / JSON-LD.

10. Math Primitives Reference

iridis exports every internal colour-math primitive as a singleton class (packages/core/src/math/). Each has a single apply() method — no shared base class, no state. If you only need one calculation, import the singleton directly without touching the pipeline:

import { luminance, contrastWcag21, oklchToRgb } from '@studnicky/iridis';

const ratio = contrastWcag21.apply(foreground, background);
const rgb = oklchToRgb.apply(0.62, 0.18, 290);

The full-formula walkthroughs for WCAG 2.1 and APCA contrast math live in the live demo cards on this site (see the contrast and color-space explorer components), not duplicated here — the table below documents the callable surface only.

Real implementations

Three of the more interesting primitives, straight from source:

luminance is the WCAG 2.1 relative-luminance calculation that every contrast primitive builds on:

import type { ColorRecordInterfaceType } from '../types/index.ts';

import { srgbToLinear } from './SrgbToLinear.ts';

class Luminance {
  readonly 'name' = 'luminance';

  apply(color: ColorRecordInterfaceType): number {
    const lin = srgbToLinear.apply(color.rgb.r, color.rgb.g, color.rgb.b);
    return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b;
  }
}

/** Singleton instance registered as the `luminance` math primitive. */
export const luminance = new Luminance();

contrastWcag21 composes luminance into the standard (lighter + 0.05) / (darker + 0.05) ratio:

import type { ColorRecordInterfaceType } from '../types/index.ts';

import { luminance } from './Luminance.ts';

class ContrastWcag21 {
  readonly 'name' = 'contrastWcag21';

  apply(a: ColorRecordInterfaceType, b: ColorRecordInterfaceType): number {
    const l1 = luminance.apply(a);
    const l2 = luminance.apply(b);
    const lighter = Math.max(l1, l2);
    const darker  = Math.min(l1, l2);
    return (lighter + 0.05) / (darker + 0.05);
  }
}

/** Singleton instance registered as the `contrastWcag21` math primitive. */
export const contrastWcag21 = new ContrastWcag21();

ensureContrast is the least trivial of the set — it walks OKLCH lightness in a bounded loop until a foreground clears a target ratio against a background, preserving the color's original gamut (sRGB vs. wide-gamut) on the way back out:

import type { ColorRecordInterfaceType, ContrastAlgorithmType, RgbInterfaceType, SourceFormatType } from '../types/index.ts';

import { clamp01 } from './Clamp01.ts';
import { colorRecordFactory } from './ColorRecordFactory.ts';
import { oklchToRgbRaw } from './OklchToRgbRaw.ts';
import { srgbToLinear } from './SrgbToLinear.ts';

const APCA_NORM_BG  = 0.56;
const APCA_NORM_TXT = 0.57;
const APCA_CLAMP    = 0.022;
const APCA_CLAMP_P  = 1.414;
const APCA_SCALE    = 1.14;
const APCA_LOW_CLIP = 0.001;
const APCA_OFFSET   = 0.027;

/** sRGB-family source formats. When the original foreground was sourced from
 *  one of these, the adjusted record must remain sRGB (no displayP3 slot).   */
const SRGB_FORMATS: ReadonlySet<SourceFormatType> = new Set([
  'hex', 'hsl', 'imagePixel', 'lab', 'named', 'rgb'
]);

/** WCAG 2.1 relative luminance from a gamma-encoded sRGB triple in 0..1.
 *  Equivalent to `luminance.apply({rgb: {r, g, b}, ...})` without the
 *  record allocation.                                                    */
function rgbLuminance(r: number, g: number, b: number): number {
  const lin = srgbToLinear.apply(r, g, b);
  return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b;
}

/** WCAG 2.1 contrast ratio between two precomputed luminances. */
function wcagRatio(la: number, lb: number): number {
  const lighter = Math.max(la, lb);
  const darker  = Math.min(la, lb);
  return (lighter + 0.05) / (darker + 0.05);
}

/** APCA Lc foreground luminance (text exponent). */
function apcaFg(r: number, g: number, b: number): number {
  const lin = srgbToLinear.apply(r, g, b);
  return 0.2126729 * Math.pow(lin.r, 0.56)
       + 0.7151522 * Math.pow(lin.g, 0.56)
       + 0.0721750 * Math.pow(lin.b, 0.56);
}

/** APCA Lc background luminance (background exponent). */
function apcaBg(r: number, g: number, b: number): number {
  const lin = srgbToLinear.apply(r, g, b);
  return 0.2126729 * Math.pow(lin.r, 0.65)
       + 0.7151522 * Math.pow(lin.g, 0.65)
       + 0.0721750 * Math.pow(lin.b, 0.65);
}

/** APCA Lc absolute value between text and background, computed from
 *  the text's foreground-luminance and the precomputed background. */
function apcaLcFromYtxt(Ytxt: number, Ybg: number): number {
  const txtClamp = Ytxt < APCA_CLAMP ? Ytxt + Math.pow(APCA_CLAMP - Ytxt, APCA_CLAMP_P) : Ytxt;
  const bgClamp  = Ybg  < APCA_CLAMP ? Ybg  + Math.pow(APCA_CLAMP - Ybg,  APCA_CLAMP_P) : Ybg;

  let Lc = 0;
  if (bgClamp > txtClamp) {
    Lc = (Math.pow(bgClamp, APCA_NORM_BG) - Math.pow(txtClamp, APCA_NORM_TXT)) * APCA_SCALE;
    if (Lc < APCA_LOW_CLIP) {return 0;}
    Lc = Lc - APCA_OFFSET;
  } else {
    Lc = (Math.pow(bgClamp, 0.62) - Math.pow(txtClamp, 0.65)) * APCA_SCALE;
    if (Lc > -APCA_LOW_CLIP) {return 0;}
    Lc = Lc + APCA_OFFSET;
  }
  return Math.abs(Lc * 100);
}

class EnsureContrast {
  readonly 'name' = 'ensureContrast';

  /**
   * Adjusts `foreground` along the OKLCH L axis until its contrast against
   * `background` meets `minRatio` for the given `algorithm`. The inner
   * loop operates on a scalar `L` value; no `ColorRecord` is allocated
   * per iteration. The background's luminance is computed once. A single
   * `ColorRecord` is materialised at return via `colorRecordFactory`.
   *
   * Gamut preservation: the adjusted record inherits `foreground.sourceFormat`.
   * When that format is an sRGB-family format (`hex`, `rgb`, `hsl`, `lab`,
   * `named`, `imagePixel`), the record is built via `fromRgb` using the
   * sRGB-clamped coordinates computed during the loop — preventing a
   * spurious `displayP3` slot from appearing on a colour that was never
   * wide-gamut. When the source is `displayP3` or `oklch`, `fromOklch` is
   * used so the wide-gamut `displayP3` slot is re-derived as expected.
   */
  apply(
    foreground: ColorRecordInterfaceType,
    background: ColorRecordInterfaceType,
    minRatio: number,
    algorithm: ContrastAlgorithmType = 'wcag21'
  ): ColorRecordInterfaceType {
    // Precompute background luminance once; it never changes during the loop.
    const bgRgb: RgbInterfaceType = background.rgb;
    const Ybg = algorithm === 'wcag21'
      ? rgbLuminance(bgRgb.r, bgRgb.g, bgRgb.b)
      : apcaBg(bgRgb.r, bgRgb.g, bgRgb.b);

    // Compute initial foreground contrast from its existing rgb.
    const fgRgb: RgbInterfaceType = foreground.rgb;
    const initialContrast = algorithm === 'wcag21'
      ? wcagRatio(rgbLuminance(fgRgb.r, fgRgb.g, fgRgb.b), Ybg)
      : apcaLcFromYtxt(apcaFg(fgRgb.r, fgRgb.g, fgRgb.b), Ybg);

    if (initialContrast >= minRatio) {
      return foreground;
    }

    const fgL    = foreground.oklch.l;
    const c      = foreground.oklch.c;
    const h      = foreground.oklch.h;
    const a      = foreground.alpha;
    const fmt    = foreground.sourceFormat;
    const hints  = foreground.hints;
    const isSrgb = SRGB_FORMATS.has(fmt);
    const step   = fgL < background.oklch.l ? -0.02 : 0.02;

    let currentL = fgL;
    let lastL    = currentL;
    let lastRgb: RgbInterfaceType = fgRgb;

    for (let i = 0; i < 50; i++) {
      const newL = clamp01.apply(currentL + step);
      const rgb  = oklchToRgbRaw.apply(newL, c, h);

      const ratio = algorithm === 'wcag21'
        ? wcagRatio(rgbLuminance(rgb.r, rgb.g, rgb.b), Ybg)
        : apcaLcFromYtxt(apcaFg(rgb.r, rgb.g, rgb.b), Ybg);

      lastL   = newL;
      lastRgb = rgb;

      if (ratio >= minRatio) {
        return isSrgb
          ? colorRecordFactory.fromRgb(rgb.r, rgb.g, rgb.b, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt })
          : colorRecordFactory.fromOklch(newL, c, h, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt });
      }

      if (newL === 0 || newL === 1) {
        return isSrgb
          ? colorRecordFactory.fromRgb(rgb.r, rgb.g, rgb.b, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt })
          : colorRecordFactory.fromOklch(newL, c, h, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt });
      }

      currentL = newL;
    }

    return isSrgb
      ? colorRecordFactory.fromRgb(lastRgb.r, lastRgb.g, lastRgb.b, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt })
      : colorRecordFactory.fromOklch(lastL, c, h, { 'alpha': a, 'hints': hints, 'sourceFormat': fmt });
  }
}

/** Singleton instance registered as the `ensureContrast` math primitive. */
export const ensureContrast = new EnsureContrast();

Color-space conversion

PrimitiveSignatureDescription
hexToRgbapply(hex: string): ColorRecordParses a #rrggbb/#rgb string into a full ColorRecord.
rgbToHexapply(r, g, b): stringEncodes 0..1 RGB channels back to #rrggbb.
rgbToHslapply(r, g, b, alpha?): HslResultConverts sRGB to HSL.
hslToRgbapply(h, s, l, alpha?): ColorRecordConverts HSL back to a ColorRecord.
rgbToOklchapply(r, g, b, alpha?): ColorRecordConverts sRGB to OKLCH via linear-light space.
oklchToRgbapply(l, c, h, alpha?): ColorRecordConverts OKLCH to a gamut-mapped sRGB ColorRecord.
oklchToRgbRawapply(l, c, h): RgbSame conversion without gamut mapping or ColorRecord wrapping.
oklchToDisplayP3apply(l, c, h): RgbConverts OKLCH into Display P3 primaries for wide-gamut output.
srgbToLinear / linearToSrgbapply(r, g, b): RgbsRGB companding transfer functions in each direction.
gamutMapSrgbapply(l, c, h): GamutMapResultReduces OKLCH chroma along a hue line until the color fits the sRGB gamut.

Contrast & accessibility

PrimitiveSignatureDescription
luminanceapply(color: ColorRecord): numberRelative luminance per WCAG 2.1.
contrastWcag21apply(a, b: ColorRecord): numberWCAG 2.1 contrast ratio between two colors.
contrastApcaapply(text, background: ColorRecord): numberAPCA (WCAG 3 draft) perceptual contrast (Lc).
contrastTextapply(background: ColorRecord, threshold = 0.179): ColorRecordPicks black or white text for a given background by luminance threshold.
ensureContrastapply(...)Iteratively nudges a foreground color's OKLCH lightness until it clears a target contrast ratio against a background. Backs enforce:contrast and the VS Code modifier pass.
deltaE2000apply(a, b: ColorRecord): numberCIEDE2000 perceptual color-difference metric.

Mixing & adjustment

PrimitiveSignatureDescription
mixOklch / mixSrgb / mixHslapply(a, b: ColorRecord, t: number): ColorRecordLinear-interpolate two colors in the named color space.
lighten / darkenapply(color: ColorRecord, deltaL: number): ColorRecordShifts OKLCH lightness by a delta, clamped to [0, 1].
saturate / desaturateapply(color: ColorRecord, deltaC: number): ColorRecordShifts OKLCH chroma by a delta, clamped to [0, 0.5].
hueShiftapply(color: ColorRecord, degrees: number): ColorRecordRotates OKLCH hue by the given degrees.

Clustering

PrimitiveSignatureDescription
clusterMedianCutapply(colors, k: number): ColorRecord[]Reduces a color set to k representative colors via unweighted median-cut.
clusterMedianCutWeightedapply(colors, k: number): ColorRecord[]Median-cut variant that weights bins by pixel/sample frequency; backs gallery:extract and clamp:count.
clusterDeltaEMergeapply(colors, k: number): ColorRecord[]Merges colors within a perceptual (deltaE2000) distance threshold down to k clusters.

Utility

PrimitiveSignatureDescription
clampapply(min, max, v: number): numberGeneric numeric clamp.
clamp01apply(v: number): numberClamps to [0, 1], used throughout the channel/lightness/chroma math.
colorRecordFactoryBuilds a fully-populated ColorRecord (rgb, oklch, hex, and optional displayP3) from any single representation.

11. Architecture Internals

The Task Registry

The TaskRegistry is the spine of the engine. It acts as a map of TaskInterface objects. Every task has a string name (e.g., 'intake:any').

import { TaskRegistry } from '@studnicky/iridis';

const registry = new TaskRegistry();
registry.register(myCustomTask);
registry.has('my:custom');          // true

The Engine owns one TaskRegistry instance (engine.tasks). When you call engine.pipeline(['intake:any', 'resolve:roles', ...]), the engine validates that every name is registered before storing the execution sequence.

Its real implementation is small: a name-keyed Map, plus two lifecycle queues (onRunStart, onRunEnd) that hooked tasks additionally join.

import { ModuleError, ValidationError } from '@studnicky/errors';

import type {
  LifecyclePhaseType,
  TaskInterface,
  TaskManifestInterfaceType,
  TaskRegistryInterface
} from '../types/index.ts';

/**
 * Holds the mutable set of pipeline tasks an `Engine` will run. Tasks
 * are addressed by name, so registering a task with an existing name is
 * a deliberate override (used by plugins to swap built-ins).
 *
 * Hook tasks live in the same name table as ordered tasks but additionally
 * reside in per-phase queues fired by the engine before/after the main
 * sequence. The phase a task was hooked into is the phase it fires in;
 * the same task may not currently be hooked into multiple phases.
 */
export class TaskRegistry implements TaskRegistryInterface {
  private readonly entries = new Map<string, TaskInterface>();

  private readonly onRunStart: TaskInterface[] = [];

  private readonly onRunEnd:   TaskInterface[] = [];

  register(task: TaskInterface): void {
    if (task.name === '') {
      throw ValidationError.create({ 'message': 'task.name is required', 'path': 'TaskRegistry.register' });
    }
    this.entries.set(task.name, task);
  }

  hook(phase: LifecyclePhaseType, task: TaskInterface): void {
    if (task.name === '') {
      throw ValidationError.create({ 'message': 'task.name is required', 'path': 'TaskRegistry.hook' });
    }
    this.entries.set(task.name, task);
    if (phase === 'onRunStart') {
      this.onRunStart.push(task);

      return;
    }
    this.onRunEnd.push(task);
  }

  resolve(name: string): TaskInterface {
    const task = this.entries.get(name);

    if (task === undefined) {
      throw ModuleError.create(`TaskRegistry.resolve: no task registered with name '${name}'`, {
        'context':  { 'taskName': name },
        'scenario': 'NOT_FOUND'
      });
    }

    return task;
  }

  has(name: string): boolean {
    const result = this.entries.has(name);
    return result;
  }

  list(): readonly TaskManifestInterfaceType[] {
    const out: TaskManifestInterfaceType[] = [];

    for (const task of this.entries.values()) {
      out.push(task.manifest ?? { 'name': task.name });
    }

    return out;
  }

  hooks(phase: LifecyclePhaseType): readonly TaskInterface[] {
    return phase === 'onRunStart' ? this.onRunStart : this.onRunEnd;
  }
}

Shared State

Every task receives the same mutable PaletteStateInterface object during execution. Tasks read from and write to named slots on this state.

A task's dependencies are documented via the reads and writes arrays in its manifest (which you can see visualized in the Pipeline accordion on the The Four Stages card).

readonly manifest: TaskManifestInterface = {
  name:    'resolve:roles',
  reads:   ['colors', 'input.roles'],
  writes:  ['roles', 'metadata'],
};

The engine does not enforce dependency ordering at runtime—that is your responsibility via the pipeline array. If a task writes state.roles and a later task reads state.roles, your engine.pipeline() array must reflect that execution order.

The manifest shape itself, along with the TaskInterface every task implements:

import type { LoggerInterface } from '@studnicky/logger/interfaces';

import type { EngineInterface } from './engine.ts';
import type { TaskRegistryInterface } from './registry.ts';
import type { PaletteStateInterface } from './state.ts';

export type LifecyclePhaseType = 'onRunStart' | 'onRunEnd';

export interface PipelineContextInterface {
  readonly 'engine':    EngineInterface;
  readonly 'logger':    LoggerInterface;
  readonly 'startedAt': number;
  readonly 'tasks':     TaskRegistryInterface;
}

export type TaskManifestInterfaceType = {
  'description'?:   string;
  'name':           string;
  'phase'?:         LifecyclePhaseType;
  'reads'?:         string[];
  'requires'?:      string[];
  'writes'?:        string[];
};

export interface TaskInterface {
  readonly 'manifest'?: TaskManifestInterfaceType;
  readonly 'name':      string;
  run(state: PaletteStateInterface, ctx: PipelineContextInterface): void;
}

12. Roadmap: Living Color

Roadmap: living color

The architecture described in Architecture Internals derives one point. The v2 thesis is that the same engine can animate between points and continue to enforce the same constraints, WCAG, role schemas, palette algebra, on every frame.

A palette of N roles is a vector in OKLCH × N-roles space, length 3·N:

[L₁, C₁, h₁,  L₂, C₂, h₂,  ...,  Lₙ, Cₙ, hₙ]

A static theme is a frozen point in that space. A living palette is a path through it, the way a chameleon or a cephalopod chromatophore does not pick a color but animates along a trajectory while preserving constraints (visibility, contrast, biological viability). Animation is a parameterised curve t ∈ [0, 1] → palette(t) that moves continuously from one point to another. Easings shape the curve: linear, cubic-bezier, spring, or chromatic detour (a path that visits a third point so a warm-to-cool transition passes through green rather than brown). Hue wraparound has its own degree of freedom, clockwise, counter-clockwise, or shortest-arc.

The constraint stack runs per frame. A full pipeline derivation is well under a millisecond budget for a 12-role schema, which leaves room for enforce:contrast to keep WCAG ratios satisfied at every intermediate point on the curve.

Palette algebra

Vector framing makes a small algebra fall out for free:

OperationMeaning
lerp(a, b, 0.3)thirty percent of the way from a toward b
a − bper-role OKLCH delta vector
nearest(a, corpus)closest preset to a custom palette under a perceptual metric
drift(current, derived) > θ"the user adjusted accent past tolerance, re-derive?"
perpendicular(a, axis)move orthogonally on the chroma plane while holding L and h

These are not separate features. They are consequences of treating the palette as a vector and the pipeline as a function from vector to vector.

Reactive bindings

Once the engine produces palette(t), the parameter t can come from anywhere: a clock, an audio FFT bin, scroll position, focus state, ambient light, weather data, a finite-state machine over user mood. The engine does not need to know the source. A binding layer reads a signal, maps it to t, and asks the engine for the palette at that point.

A static palette answers "what colors". A living palette answers "what colors right now, given what is happening". The wedge is the combination: vector palettes plus role schemas plus contrast enforcement plus animation plus reactive signals, in one engine, with the constraints holding on every frame. The planned iridis-anima plugin will carry this forward from v1's single-point derivation.