@hex-core/payload

Pure-function builders for the canonical LLM context markdown.

Install

Version 0.4.1Runtime dependency
pnpm
pnpm add @hex-core/payload

What it does

`@hex-core/payload` is the pure-function builder for the canonical LLM context blob. Pass it a theme + components (+ optional recipes, density), get markdown back.

It exists so non-MCP-client surfaces — Next.js Server Components, generator scripts, CI fixtures, CLI tools — can render the canonical payload without spawning a subprocess and speaking JSON-RPC over stdio. The MCP server (`@hex-core/mcp@0.4.0+`) is now a thin transport shell that wraps this package.

The package also bundles the registry data (185 items + 24 recipes as of payload 0.4.1) inside its tarball, so loaders work without filesystem coupling to the consumer's repo. This is what unblocked the studio's `/copy` route from re-implementing the markdown format locally.

Public API

buildAppContext

ts
function buildAppContext(input: {
	theme: { requested: string; resolved: Theme | null };
	components: { slug: string; item: RegistryItem | null }[];
	recipes: { slug: string; recipe: Recipe | null }[];
	density?: "compact" | "comfortable" | "spacious";
	overrides?: Record<string, string>;
}): string;

Build the full canonical markdown blob: install commands, `globals.css`, `tailwind.config.ts`, components, recipes, and the LLM context prompt. `theme.resolved` is `null` when the requested slug was unknown — the markdown surfaces the fallback so the LLM knows the user's intent vs. the active theme. `recipes` is required (pass `[]` if you have none); both `recipe` and `item` slots may be `null` when a slug doesn't resolve. The documented input above is the canonical authoring surface; the published `AppContextInput` carries a few extra optional metadata fields on `AppContextTheme` (tags / designBrief / attribution / brand / category) that are normally populated upstream rather than authored by hand.

tsx
import { buildAppContext, loadRegistryItem } from "@hex-core/payload";
import { defaultTheme } from "@hex-core/tokens";

const components = ["button", "card", "input"].map((slug) => ({
	slug,
	item: loadRegistryItem(slug),
}));

const markdown = buildAppContext({
	theme: { requested: "default", resolved: defaultTheme },
	components,
	recipes: [],
	density: "comfortable",
});

// Render markdown in a Server Component, write to disk, etc.

buildFigmaTokens

ts
interface FigmaTokensInput {
	theme: { requested: string; resolved: FigmaTokensTheme | null };
}

function buildFigmaTokens(input: FigmaTokensInput): string;

Build a deterministic markdown payload describing the chosen theme as a Figma Variables REST POST body. Pasting the JSON block into Figma's plugin or the REST endpoint produces a populated variable kit (one collection, two modes, one variable per token). `theme.resolved` is `null` when the requested slug was unknown — the markdown calls that out so designers know the fallback happened.

tsx
import { buildFigmaTokens, getTheme } from "@hex-core/payload";

const slug = "midnight";
const markdown = buildFigmaTokens({
	theme: { requested: slug, resolved: getTheme(slug) ?? null },
});
console.log(markdown.slice(0, 80));
// → "## Figma Variables payload — midnight\n..."

buildFigmaPayload

ts
interface FigmaTokensTheme {
	name: string;
	displayName: string;
	tokens: { light: Record<string, TokenValue>; dark: Record<string, TokenValue>; };
}

interface FigmaPayloadResult {
	payload: FigmaVariablesPayload;
	skipped: Array<{ key: string; type: TokenValue["type"] }>;
}

function buildFigmaPayload(theme: FigmaTokensTheme): FigmaPayloadResult;

Lower-level companion to `buildFigmaTokens` — returns the raw `FigmaVariablesPayload` object plus a `skipped` list of tokens whose type Figma can't represent as a primitive Variable (`shadow`, `gradient`, `cubicBezier`). Caller surfaces `skipped` so designers see what didn't transfer; a silent zero-value FLOAT fall-through would be worse. Iterates the union of light + dark keys so dark-only tokens land in the kit.

tsx
import { buildFigmaPayload } from "@hex-core/payload";
import { defaultTheme } from "@hex-core/tokens";

const { payload, skipped } = buildFigmaPayload(defaultTheme);

console.log(payload.variables.length);
// → number of tokens emitted as Figma Variables

if (skipped.length > 0) {
	console.warn("Figma can't represent:", skipped);
	// → [{ key: "shadow-md", type: "shadow" }, ...]
}

loadRegistryItem / loadRegistry

ts
function loadRegistryItem(slug: string): RegistryItem | null;
function loadRegistry(): RegistryIndex;

interface RegistryIndex {
	name: string;
	version: string;
	description: string;
	homepage: string;
	items: Array<{
		name: string;
		displayName: string;
		description: string;
		category: string;
		subcategory?: string;
		tags: string[];
		internalDeps: string[];
		tokenBudget?: number;
	}>;
}

Read from the bundled registry snapshot. `loadRegistryItem(slug)` returns the FULL `RegistryItem` (or `null` for an unknown slug) — that's the function you reach for when you need props/variants/AI hints. `loadRegistry()` returns the slim index (`items[]` are summaries, not full RegistryItems) — use it to enumerate the catalog or drive a search/picker UI. Server-only: both perform synchronous filesystem reads, so don't import them from a Client Component.

tsx
import { loadRegistry, loadRegistryItem } from "@hex-core/payload";

const idx = loadRegistry();
console.log(idx.items.length); // 185 (at @0.4.1)

const button = loadRegistryItem("button");
if (button) {
	console.log(button.ai.whenToUse);
}

loadRecipes / loadRecipe

ts
function loadRecipes(): RecipeIndex;
function loadRecipe(slug: string): Recipe | null;

interface RecipeIndex {
	name: string;
	version: string;
	items: Array<{
		slug: string;
		title: string;
		summary: string;
		tags: string[];
		components: string[];   // flat slug list, derived from steps
		tokenBudget?: number;
	}>;
}

Same split as the component loaders. `loadRecipes()` returns the slim `RecipeIndex` — each `items[]` entry carries a derived `components` slug list for catalog UIs. `loadRecipe(slug)` returns the FULL `Recipe`, whose enumerable structure is `recipe.steps.map((s) => s.component)` (the recipe object itself has no `components` field). Pass loaded recipes into `buildAppContext`'s required `recipes` field to bundle them into the LLM payload (use `[]` when you have none).

tsx
import { loadRecipes, loadRecipe } from "@hex-core/payload";

// Index path: pull the components list straight off the index entry.
const idx = loadRecipes();
const auth = idx.items.find((i) => i.slug === "auth-form");
console.log(auth?.components); // ["button", "input", "label", ...]

// Full-recipe path: components live under steps, not directly on Recipe.
const full = loadRecipe("auth-form");
console.log(full?.steps.map((s) => s.component));

getTheme / listThemes

ts
function getTheme(name: string): Theme | undefined;
function listThemes(): readonly {
	name: string;
	displayName: string;
	description: string;
	category?: string;
	tags?: string[];
	brand?: string;
}[];

Re-exports of the theme catalog from `@hex-core/tokens`. `listThemes()` is metadata-only — entries carry `name` / `displayName` / `description` (plus optional `category` / `tags` / `brand`) so a picker UI can render labels without paying for token data. To retrieve the full strict `Theme` object call `getTheme(name)`. Convenient when you already have `@hex-core/payload` installed and don't want to add a second dependency just to look up a theme by name.

tsx
import { getTheme, listThemes } from "@hex-core/payload";

const theme = getTheme("midnight") ?? getTheme("default");
console.log(listThemes().map((t) => t.name));

resolveSpec (with ResolveResult / ComponentMatch / RecipeMatch / ResolverOptions)

ts
interface ComponentMatch {
	component: string;
	displayName: string;
	score: number;
	matchReason: string[];
	whenToUse: string;
	whenNotToUse: string;
	relatedComponents: string[];
	tokenBudget?: number;
}

interface RecipeMatch {
	slug: string;
	title: string;
	summary: string;
	score: number;
	matchReason: string[];
}

interface ResolveResult {
	brief: string;
	components: ComponentMatch[];
	recipes: RecipeMatch[];
}

interface ResolverOptions {
	limit?: number;
	registry?: RegistryIndex;
	recipes?: RecipeIndex;
}

function resolveSpec(brief: string, options?: ResolverOptions): ResolveResult;

Deterministic brief-to-shortlist resolver. Pass a natural-language description ("settings page with profile, billing, notifications"); get back a ranked list of components + recipes with per-match `score` + `matchReason` + the AI-hint context (`whenToUse` / `whenNotToUse` / `relatedComponents`). Same logic the MCP `resolve_spec` tool wraps. Deterministic: same brief + same registry snapshot always produces the same output, so agents can cite a slug with confidence and CI can regression-test against fixed briefs. Pass `options.limit` to cap matches per category; pass `options.registry` / `options.recipes` to score against an alternate snapshot.

tsx
import { resolveSpec } from "@hex-core/payload";

const result = resolveSpec("confirm-destructive dialog with checkbox and dual-action footer");

for (const m of result.components.slice(0, 3)) {
	console.log(`${m.score.toFixed(2)} ${m.component} — ${m.matchReason[0]}`);
}
// → 0.92 dialog — "destructive" matched whenToUse
// → 0.81 button — "dual-action footer" matched whenToUse
// → 0.74 checkbox — explicit slug match

Workflows

Render the canonical payload from a Next.js Server Component

This is what the studio's `/copy` page does. No subprocess, no JSON-RPC, no MCP transport — direct function call.

tsx
// app/llm-context/page.tsx
import { buildAppContext, loadRegistryItem } from "@hex-core/payload";
import { defaultTheme } from "@hex-core/tokens";

export default function LlmContextPage() {
	const components = ["button", "card", "input", "dialog"].map((slug) => ({
		slug,
		item: loadRegistryItem(slug),
	}));

	const markdown = buildAppContext({
		theme: { requested: "default", resolved: defaultTheme },
		components,
		recipes: [],
	});

	return <pre>{markdown}</pre>;
}

Generate a Figma tokens file in a CI script

Run on every theme change to keep design and code in sync.

ts
// scripts/sync-figma-tokens.ts
import { buildFigmaTokens, getTheme } from "@hex-core/payload";
import { writeFileSync } from "node:fs";

const requested = process.env.THEME ?? "default";
const theme = getTheme(requested);
if (!theme) throw new Error("Unknown theme");

const markdown = buildFigmaTokens({
	theme: { requested, resolved: theme },
});

writeFileSync("./design/figma-tokens.md", markdown);
console.log("Wrote figma-tokens.md");

Compatibility

  • Pure ESM. Server-only — `loadRegistry*` and `loadRecipe*` perform synchronous `fs` reads. Do not import from a Client Component.
  • Bundles `registry/` data into the published tarball. Works in fresh `npm install` outside the hex-core monorepo.
  • Depends on `@hex-core/registry@^0.3.2`, `@hex-core/tokens@^1.3.2`, and `@hex-core/themes@^0.2.0`. Re-exports `RegistryItem`, `Recipe`, `RegistryIndex`, `RecipeIndex` and the Figma + resolver types directly; the `Theme` type comes from `@hex-core/registry` (the doc's `Theme` references above point there — `@hex-core/payload` does not re-export it).

See also

Verified against @hex-core/components@1.14.0 · @hex-core/tokens@1.3.7 · @hex-core/themes@0.2.3 · @hex-core/registry@0.6.0 · @hex-core/payload@0.4.1