refract v0.1 · beta
Adapters / Writing an adapter

Writing an adapter

An adapter is the only place output format lives — the Model holds no CSS syntax. To target a new format (React Native, Compose, a design-token pipeline) you implement four required primitives; core supplies everything else. defineAdapter fills in the aggregators.

The adapter contract

The bundle a third-party adapter builds against is four exported types plus one subpath: you author an AdapterSpec whose bind() returns a BoundSpec, wrap it with defineAdapter to get a ThemeAdapter, and pull the shared naming/override machinery from @theme-registry/refract/adapter-kit (createNamer, resolveNaming, varNameFromPath, collision detection). AdapterSpec.version is the contract number external packages pin to. Every first-party adapter imports only these public entries — no deep imports into src/, a boundary a committed packaging test enforces — so "CSS is just another adapter" holds at the import level.

The four primitives

Core walks the Model — every subsystem, every (group, variant) — and calls these. You only describe how one unit renders in your format; join says how units combine.

MethodSignatureIts job
recipeName(sub, group, variant) => stringA rule-set's identity in your format — a CSS class, an RN style key.
renderRecipe(sub, group, variant) => TUnitRender one rule-set: base declarations + its state / responsive overrides.
renderVariables(sub) => TUnitRender one subsystem's tokens (its :root vars in CSS; an object in JSON).
join(parts: TUnit[]) => TUnitCombine units. CSS: parts.join("\n\n"); JSON: merge fragments; RN: merge objects.

TUnit is your format's unit of output — string for CSS/SCSS, a document fragment for JSON, a style object for RN. Everything is generic over it.

The bind context

Your bind(model, ctx) runs once. ctx hands you the tools so you never re-derive theme state:

ctx memberTypeWhat it gives you
mediaMediaDescriptorBreakpoints → an @media builder (already unit-resolved from createTheme's media).
containersContainerDescriptorsPer-named-container @container builders; empty when no containers.
resolve(path) => LiteralResolve a token path to a concrete literal — for inline / value-mode output.

Optional hooks

HookWhen to implement
allowedStatesThe states your format understands (CSS: hover/disabled/…). Core validates recipe state: refs against it; absent ⇒ any state accepted. Declared at adapter level (normalization runs before bind).
extend(theme)Attach runtime-only helpers to the theme root (the theme.media / theme.classes pattern).
emit(plan)Build-time file output — switch on plan.type (single / split / subsystem / components) and throw for modes you don't support.
renderAll* overridesOverride the defaulted aggregators when your full document isn't a flat concatenation (JSON overrides these to merge buckets).

Skeleton

my-adapter.ts
import { defineAdapter } from "@theme-registry/refract";

export const createMyAdapter = (options = {}) =>
  defineAdapter({
    name: "my-format",
    version: 1,
    allowedStates: ["hover", "disabled"],   // omit ⇒ any state accepted
    bind(model, ctx) {
      return {
        recipeName(sub, group, variant) { return `${sub}-${group}-${variant}`; },
        renderRecipe(sub, group, variant) { /* … your format … */ },
        renderVariables(sub) { /* … tokens for one subsystem … */ },
        join(parts) { return parts.join("\n\n"); },
      };
    },
  });

// then, exactly like every built-in adapter:
const theme = createTheme(raw, { adapter: createMyAdapter() });
defineAdapter wraps your bind so the returned surface gains the defaulted renderAllRecipes / renderAllVariables / renderAll — pure Model walks over your four primitives. You write the format-specific 20%; core owns the other 80%.
Technical documentation for @theme-registry/refract · all output is compiled client-side by the real library (CSS adapter). The chrome is theme-aware; the render panes carry each preset's own world. · MIT licensed.