@hex-core/payload

Pure-function builders for the canonical LLM context markdown.

Install

Version 0.3.0Runtime 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 (164 items as of payload 0.3.0) 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); // 164 (at @0.3.0)

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.12.0 · @hex-core/tokens@1.3.6 · @hex-core/themes@0.2.2 · @hex-core/registry@0.5.1 · @hex-core/payload@0.3.0