@hex-core/tokens

HSL design tokens, theme presets, and CSS/Tailwind transformers.

Install

Version 1.3.7Runtime dependency
pnpm
pnpm add @hex-core/tokens

What it does

`@hex-core/tokens` is the design-token data layer. It exports three theme objects (`defaultTheme`, `midnightTheme`, `emberTheme`) plus four transformers that turn those objects into the formats your build pipeline understands: CSS, Tailwind config, raw token sets, and runtime-injectable scoped CSS.

Tokens are the *names* that components reference (`--color-primary`, `--space-4`). Themes are the *values* assigned to those names. The transformers are the bridges. Components, the studio, and the MCP server all read from this single source.

Two namespaces exist intentionally: `themeToCss` emits raw triplets (`--primary: 240 5.9% 10%`) for alpha-composition utilities; `themeToScopedRuntimeCss` emits both the raw form and the `--color-<key>: hsl(...)` form Tailwind v4 expects in `@theme`. Don't hand-roll the second.

Public API

defaultTheme / midnightTheme / emberTheme

ts
import { defaultTheme, midnightTheme, emberTheme } from "@hex-core/tokens";
import type { Theme } from "@hex-core/registry";

const t: Theme = defaultTheme; // strict; tokens align with @hex-core/registry@^0.3.2

Three built-in theme objects shaped against `@hex-core/registry`'s strict `Theme` type. Each carries `tokens.light`, `tokens.dark`, and metadata. New presets land in `@hex-core/themes`, not here — `tokens` ships only the foundational three.

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

console.log(defaultTheme.tokens.light.primary);
// → { value: "222 25% 18%", type: "color" }

themeToCss

ts
function themeToCss(theme: Theme): string;

Emit the raw-triplet CSS namespace (`--<key>: <H> <S>% <L>%`) for both modes in one pass. The output always wraps `:root {}` (light) and `.dark {}` (dark) in an `@layer base { ... }` block — there's no options arg to pick a single mode. Useful when you need alpha composition (`hsl(var(--primary) / 0.5)`). For mode-aware single-palette output (e.g. runtime overrides on a scoped surface), reach for `themeToScopedRuntimeCss`, which does accept options.

tsx
import { themeToCss, defaultTheme } from "@hex-core/tokens";

const css = themeToCss(defaultTheme);
// → "@layer base { :root { --background: 210 20% 98%; ... } .dark { --background: 240 10% 4%; ... } }"

themeToScopedRuntimeCss

ts
function themeToScopedRuntimeCss(theme: Theme, options?: {
	mode?: "light" | "dark";
	scope?: string;
}): string;

Emit BOTH namespaces in one rule: raw `--<key>: <value>;` AND `--color-<key>: hsl(<value>);`. Designed for runtime overrides (think: a studio that mutates the canvas without rebuilding Tailwind). Non-color tokens emit only the raw form.

tsx
import { themeToScopedRuntimeCss, defaultTheme } from "@hex-core/tokens";

const css = themeToScopedRuntimeCss(defaultTheme, {
	mode: "dark",
	scope: ".studio-canvas",
});
// → ".studio-canvas.dark { --primary: 0 0% 98%; --color-primary: hsl(0 0% 98%); ... }"

themeToTailwindConfig

ts
function themeToTailwindConfig(theme: Theme): Record<string, Record<string, string>>;

Generate a flat top-level map of Tailwind theme slots — `colors`, `borderRadius`, `spacing`, `fontSize`, `transitionDuration`, `height` — each a `Record<string, string>` of token names to CSS-variable references. Spread it under `theme.extend` in `tailwind.config.ts` if you're still on Tailwind v3 (each returned key maps to its matching `theme.extend.*` slot — `colors` → `theme.extend.colors`, `borderRadius` → `theme.extend.borderRadius`, etc.). Tailwind v4 consumers prefer the `@theme {}` block instead.

tsx
import { themeToTailwindConfig, defaultTheme } from "@hex-core/tokens";

export default {
	content: ["./src/**/*.{ts,tsx}"],
	theme: { extend: { ...themeToTailwindConfig(defaultTheme) } },
};

generateGlobalsCss

ts
function generateGlobalsCss(theme: Theme, options?: {
	target?: "v3" | "v4";  // defaults to "v3"
}): string;

One-shot helper that emits the full `globals.css` body. The `target` option picks the Tailwind major: `"v3"` (default, backward compatible) emits `@tailwind base/components/utilities` + `@layer base { :root {} .dark {} }` blocks (pair with a `tailwind.config.ts` that maps the raw `--<key>` variables to color/radius utilities); `"v4"` emits `@import "tailwindcss"` + `@theme { --color-<key>: hsl(...) }` so utilities like `bg-background` resolve directly with no `tailwind.config.ts` needed. Used by the CLI's `hex init` and the MCP `emit_app_context` tool.

tsx
import { generateGlobalsCss, defaultTheme } from "@hex-core/tokens";
import { writeFileSync } from "node:fs";

// Tailwind v4 (recommended for new projects).
writeFileSync("./globals.css", generateGlobalsCss(defaultTheme, { target: "v4" }));

// Tailwind v3 (default — emits @tailwind directives + @layer base).
writeFileSync("./globals.v3.css", generateGlobalsCss(defaultTheme));

getTheme / listThemes

ts
function getTheme(name: string): Theme | undefined;
function listThemes(): Array<{ name: string; displayName: string; description: string }>;

Enumerate the three built-in themes (`default`, `midnight`, `ember`) by name. `getTheme` returns the full Theme object or `undefined`; `listThemes` returns just metadata. The studio's theme switcher uses these; so do the MCP / CLI surfaces that don't want to add a separate `@hex-core/themes` dep just to look up a name.

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

const theme = getTheme("midnight") ?? getTheme("default");
console.log(listThemes().map((t) => t.name));
// → ["default", "midnight", "ember"]

themeToFlatJson

ts
function themeToFlatJson(theme: Theme, mode?: "light" | "dark"): Record<string, string>;

Flatten a theme into a single key-value record (`{ "--primary": "240 5.9% 10%", "--radius": "0.375rem", ... }`) suitable for Style Dictionary interop, Figma Variables import, or any pipeline that wants raw token values without the `{ value, type }` wrapper. Keys are the CSS-variable form (prefixed with `--`).

tsx
import { themeToFlatJson, defaultTheme } from "@hex-core/tokens";

const flat = themeToFlatJson(defaultTheme, "light");
console.log(flat["--primary"]);    // → "222 25% 18%"
console.log(flat["--radius"]);     // → "0.375rem"

Color derivation utilities

ts
function deriveForegroundFor(bgValue: string, opts?: DeriveForegroundOptions): ColorToken;
function deriveDarkFromLight(light: TokenSet, opts?: DeriveDarkOptions): TokenSet;
function deriveSecondaryFromPrimary(primaryValue: string, opts?: DeriveSecondaryOptions): ColorToken;
function colorInputToTokenValue(input: string): string | null;
function contrastRatio(fgValue: string, bgValue: string): number;
function tokenLuminance(value: string): number;

Pure HSL-triplet color math used by the brand-onboarding flow. `deriveForegroundFor` picks near-white or near-black per WCAG contrast; `deriveDarkFromLight` mirrors a light-mode set into a coherent dark-mode set (with foregrounds re-derived for AA); `deriveSecondaryFromPrimary` produces the muted-but-related fill Cancel/Save-Draft buttons want; `colorInputToTokenValue` parses anything CSS-color-spec accepts (hex, named colors, `hsl(...)`) into the canonical HSL-triplet shape; `contrastRatio` + `tokenLuminance` are the WCAG building blocks. All zero-DOM, zero-React.

tsx
import {
	defaultTheme,
	deriveDarkFromLight,
	deriveForegroundFor,
	deriveSecondaryFromPrimary,
	colorInputToTokenValue,
} from "@hex-core/tokens";

// User pastes a hex from Figma → snapped to the canonical token shape.
const primary = colorInputToTokenValue("#0F62FE"); // "219 99.2% 52.7%"

// Pair the primary with an AA-contrast foreground.
const fg = deriveForegroundFor(primary!).value;

// Build the secondary fill that lives next to the primary.
const secondary = deriveSecondaryFromPrimary(primary!);

// Ship a coherent dark-mode set without authoring it manually.
const darkTokens = deriveDarkFromLight(defaultTheme.tokens.light);

buildTokenSet (with TokenSetSeeds)

ts
interface TokenSetSeeds {
	primary: string;        // HSL triplet, brand color
	foreground: string;     // page text default
	background: string;     // page canvas
	destructive: string;    // error / delete CTA
	radius: string;         // rem string, e.g. "0.625rem"
}

function buildTokenSet(seeds: TokenSetSeeds, mode: "light" | "dark"): TokenSet;

Five-seed → full TokenSet builder. The same helper the CLI's `hex theme init -i` flow uses and the same one the brand-derived preset import script reaches for; both paths produce identical output given identical seeds. Picks the right surface/framing band for the chosen mode so derived neutrals (`secondary` / `muted` / `accent` / `border` / `input`) read correctly on light vs. dark canvases.

tsx
import { buildTokenSet } from "@hex-core/tokens";

const lightTokens = buildTokenSet(
	{
		primary: "217 100% 53%",
		foreground: "240 10% 3.9%",
		background: "0 0% 100%",
		destructive: "0 84% 60%",
		radius: "0.625rem",
	},
	"light",
);
console.log(Object.keys(lightTokens).length); // 20 (19 color slots + radius)

Semantic tokens layer (defaultSemanticTokens / resolveSemanticToken)

ts
import type { SemanticTokenSet } from "@hex-core/registry";

declare const defaultSemanticTokens: SemanticTokenSet;
function resolveSemanticToken(reference: string, tokenSet: TokenSet): TokenValue | null;

The **intent layer** over raw tokens. Each entry references a raw token via flat `{token-name}` syntax and adds a `useWhen` sentence describing the UI-element class it's the right choice for. Names follow `<component>.<state-or-slot>.<role>` (e.g. `button.primary.bg`, `dialog.overlay.bg`). Theme-portable — swapping the underlying theme automatically shifts every semantic entry without per-theme rewrite. Use `resolveSemanticToken` to dereference a `{token-name}` placeholder against a concrete token set.

tsx
import { defaultSemanticTokens, defaultTheme, resolveSemanticToken } from "@hex-core/tokens";

const entry = defaultSemanticTokens["button.destructive.bg"];
console.log(entry.useWhen);
// → "irreversible actions: delete, archive, deactivate, leave, force-quit"

const resolved = resolveSemanticToken(entry.value, defaultTheme.tokens.light);
console.log(resolved?.value); // → "0 65% 43%"

Chart palette (chart-1 … chart-6)

ts
// Six rotating chart-series colors per mode, accessed as CSS variables:
//   var(--chart-1, var(--primary))    // first series, falls back to primary
//   var(--chart-2, var(--primary))    // second series
//   ... through var(--chart-6, var(--primary))
//
// Each entry is a regular ColorToken in defaultTheme.tokens.{light,dark}:
defaultTheme.tokens.light["chart-1"]; // { value: "12 76% 61%", type: "color" }
defaultTheme.tokens.dark["chart-1"];  // dark-mode counterpart

Six-color chart series palette landed in 1.3.3, both modes. Mapped to CSS variables `--chart-1` through `--chart-6` with a `var(..., var(--primary))` cascade-safe fallback so chart components keep rendering even on themes that haven't authored chart slots. The dark-mode variants ship pre-tuned for `--card` / `--border` lift so SVG-rendered chart cards stay visible against dark canvases.

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

// Inspect the six rotating series colors
for (let i = 1; i <= 6; i++) {
	const key = `chart-${i}` as const;
	console.log(key, defaultTheme.tokens.light[key]?.value);
}

// In CSS, prefer the variable form so theme overrides cascade:
// .my-chart-bar:nth-child(1) { fill: var(--chart-1, var(--primary)); }
// .my-chart-bar:nth-child(2) { fill: var(--chart-2, var(--primary)); }

sharedTokens

ts
import type { TokenValue } from "@hex-core/registry";

declare const sharedTokens: Record<string, TokenValue>;

The cross-mode token slice — radius, spacing, typography — that doesn't change between light and dark. Spread it into both palettes when authoring a new theme so you don't drift on the non-color foundations.

tsx
import { sharedTokens, defaultTheme } from "@hex-core/tokens";
import type { Theme } from "@hex-core/registry";

const myTheme: Theme = {
	...defaultTheme,
	name: "brand",
	tokens: {
		light: { ...sharedTokens, /* color overrides */ },
		dark:  { ...sharedTokens, /* color overrides */ },
	},
};

COLOR_MODE_BANDS / RADIUS_PRESETS

ts
export const RADIUS_PRESETS = {
	sharp: "0.25rem",
	balanced: "0.625rem",
	soft: "0.875rem",
} as const;
export type RadiusPreset = keyof typeof RADIUS_PRESETS;

export const COLOR_MODE_BANDS = {
	light: { surface: 0.959, framing: 0.9 },
	dark: { surface: 0.159, framing: 0.16 },
} as const;
export type ColorMode = keyof typeof COLOR_MODE_BANDS;

Enumerable constants the studio's preset switcher and the CLI's interactive theme authoring depend on. `RADIUS_PRESETS` carries the three canonical chrome densities (Linear-style sharp, shadcn-canon balanced, friendly soft); `COLOR_MODE_BANDS` pins the surface (`secondary` / `muted` / `accent` lightness) and framing (`border` / `input` lightness) bands per mode so derived neutrals fit the chosen background.

tsx
import { RADIUS_PRESETS, COLOR_MODE_BANDS } from "@hex-core/tokens";

console.log(Object.keys(RADIUS_PRESETS)); // ["sharp", "balanced", "soft"]
console.log(COLOR_MODE_BANDS.light.surface); // 0.959

Workflows

Swap themes at runtime in the studio canvas

Inject a `<style>` tag with `themeToScopedRuntimeCss` output. Change the input theme to repaint the scoped surface — no Tailwind rebuild required.

tsx
import { themeToScopedRuntimeCss, midnightTheme } from "@hex-core/tokens";

const css = themeToScopedRuntimeCss(midnightTheme, {
	mode: "light",
	scope: "[data-studio-canvas]",
});

// In a Server Component:
return <style>{css}</style>;

Build a custom theme by extending an existing one

Themes are plain objects. Spread + override. The `Theme` type from `@hex-core/registry` is strict — TypeScript will tell you if you forget a token.

tsx
import { defaultTheme } from "@hex-core/tokens";
import type { Theme } from "@hex-core/registry";

export const brandTheme: Theme = {
	...defaultTheme,
	name: "brand",
	tokens: {
		...defaultTheme.tokens,
		light: {
			...defaultTheme.tokens.light,
			primary: { value: "12 100% 50%", type: "color" },
		},
	},
};

Compatibility

  • Strict types against `@hex-core/registry@^0.3.2` (`Record<string, TokenValue>`, not `Record<string, unknown>`).
  • Zero React or DOM dependencies — works in Node generators, Vite plugins, edge runtimes.
  • Token values use HSL triplets (`<H> <S>% <L>%`), the format `<input type="color">` and the brand-native ColorPicker both accept.

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