@moonfloss/frosting
v1.0.2
Published
Generate structured, deterministic color palettes from a small set of inputs. Ramps, semantic tokens, and optional palette variants.
Maintainers
Readme
🧁 frosting
frosting generates structured, deterministic color palettes from a small set of inputs. You get ramps, semantic tokens, and optional palette variants.
What it does
- Generates design-system-friendly ramps (50–950)
- Produces light and dark mode palettes
- Outputs semantic tokens (background, foreground, primary, etc.)
- Supports 1–4 brand colors
- Optional color-theory-derived palettes
- monochromatic
- adjacent
- adjacent + complementary
- triad
- tetrad
- Optional color-vision-deficiency variants
- Deterministic output (same input and options → same output); use the config
versionfield for versioning - Pure JSON output
- Tailwind theming: CSS custom properties, theme config object, and a Tailwind plugin (see Tailwind)
- Chakra UI theming: Theme extension for v2 with colors and semantic tokens (see Chakra UI)
Install
npm install @moonfloss/frostingCLI
frosting includes a small CLI: use wizard (or w) for an interactive setup, or config:path (or c:path) to read a config from a file. Output goes to stdout (use > file to save).
npx frosting
# or
frostingRun frosting or frosting help to see usage.
Options
| Option | What it does |
| ------ | ------------ |
| wizard / w | Use prompt/answer wizard (if not present, read config from config:path). |
| config:path / c:path | Read config from filepath (when not using wizard). |
| map:path / m:path | Read mapper config JSON and emit mapped output JSON instead of raw PaletteConfig. |
| exclude:list / e:list | Comma-separated variants to exclude: light, dark, cvd, or cvd:name1,name2. |
| only:list / o:list | Comma-separated variants to include (opposite of exclude). |
| css:path / css path | Write CSS custom properties (Tailwind theming vars) to the given file. |
| version:str / ver:str | Set palette version string (default "1.0.0"). |
| no-tint | Disable brand-tinted neutrals (enabled by default). |
| no-rolloff | Disable neon chroma rolloff (enabled by default). |
| filepath1 > filepath2 | filepath1 = where to write config; filepath2 = result (redirect). Without c:path, config comes from wizard prompts. |
| --version / -v | Print version and exit. |
| --help / -h | Show help. |
Shortcuts (interchangeable in exclude/only lists and scheme kinds): modes lt=light, dk=dark; CVD p=protanopia, de/deut=deuteranopia, t=tritanopia; schemes mono=monochromatic, a=adjacent, a+c=adjacent+complementary, tri=triad, tet=tetrad.
Wizard mode
frosting wizard — Interactive guided setup. You answer questions (brand vs scheme, light/dark, options); frosting builds a config, shows the JSON, and previews the palette.
Use filepath > filepath to write both: first path = where to write the config, redirect = where to write the palette.
frosting wizard
frosting wizard config.json > palette.json
frosting wizard exclude:dark only:lightConfig from file
frosting config:path (or c:path) — Read config from the given file, generate palette to stdout. Use > palette.json to save the result.
frosting config:input.json
frosting config:input.json > palette.json
frosting config:input.json css palette-vars.css
frosting config:input.json css:palette-vars.css > palette.json
frosting config:input.json exclude:cvd
frosting config:input.json exclude:cvd:protanopia,deuteranopia
frosting config:input.json version:1.2.0
frosting config:input.json no-tint no-rolloff
frosting config:input.json map:theme-map.json > theme.jsonCustom theme shape (e.g. StorefrontTheme)
To get output that matches your own types (e.g. StorefrontThemeRamp / StorefrontThemeMode), use the map option with a theme-mapper config:
- Palette config — Your usual frosting input (wizard or
config:path). - Mapper config — A JSON file with:
template— Object shape you want. Usenull(or"") for every leaf you want filled from the palette; nested structure is preserved.mappings— Optional path overrides:"target.path": "source.path". Source paths are likelight.ramps.brand1.500,light.semantic.background, or built-in aliases such aslight.surface.page,light.text.primary,light.accent.primary,light.status.warning, etc.fuzzy— Optional{ "enabled": true, "derivedAliases": true }so unmapped leaves can be filled by name matching (e.g.light.surface.page→ alias).
Example: StorefrontTheme-style output (light/dark, brand ramp 50–900, accent, text, surface, border, status, decorative)
frosting config:palette-config.json map:docs/storefront-theme-map.example.json > storefront-theme.jsonOr with the wizard:
frosting wizard map:docs/storefront-theme-map.example.json > storefront-theme.jsonThe example mapper config is at docs/storefront-theme-map.example.json. It defines a template matching StorefrontThemeMode (with optional brand, accent, text, surface, border, status, decorative) and maps frosting’s ramps and semantic/alias tokens into it. You can copy and edit that file to add or change mappings (e.g. surface.elevated, border.muted) or to point to different source paths.
Quick start
import { generatePalette } from "@moonfloss/frosting";
const palette = generatePalette({
brand: ["#7C3AED", "#F59E0B"],
});
console.log(palette);You now have:
- brand ramps
- neutral/gray ramps
- semantic tokens
- light/dark modes
- metadata about any adjustments
UI control
The @moonfloss/frosting/ui-control entrypoint includes the existing ConfigForm convenience component plus a lower-level PaletteConfigForm wrapper for custom UIs.
Default form
import { ConfigForm } from "@moonfloss/frosting/ui-control";
export function PaletteBuilder() {
return <ConfigForm />;
}Composable wrapper
PaletteConfigForm owns editable values and exposes typed field bindings, normalized input/options, an optional live palette, and a submit handler. This lets you render with your own component library while still producing the same payload that ConfigForm uses.
What you get
PaletteConfigForm render props expose:
values- raw editable form valuesfields- typed controllers for single-value fields such asinputMode,schemeKind,schemeBase, and overrides/optionsbrandColors- helpers for repeated brand color inputs:fields,add(),remove(),set(),canAdd,canRemovepaletteInput- normalizedPaletteInput | nullpaletteOptions- normalizedPaletteOptionspalette- live generated palette when the current values are validpaletteError- error produced while generating the palette (if any)isValid- whether current values are valid and generate a palette without errorshandleSubmitandsubmit()- submit helpers for custom forms
This makes it easy to:
- keep the default
ConfigFormif you want a ready-made UI - render your own inputs with another component library
- preview live palette output while handling submit separately
- plug the normalized
inputandoptionsinto your own workflow
import { PaletteConfigForm } from "@moonfloss/frosting/ui-control";
export function CustomPaletteBuilder() {
return (
<PaletteConfigForm
onSubmit={({ input, options }) => {
console.log(input, options);
}}
>
{(form) => (
<form onSubmit={form.handleSubmit}>
<button
type="button"
onClick={() => form.fields.inputMode.onChange("scheme")}
>
Use scheme
</button>
<input
value={form.fields.schemeBase.value}
onChange={(event) =>
form.fields.schemeBase.onTextChange(event.target.value)
}
/>
<button type="submit" disabled={!form.isValid}>
Submit
</button>
</form>
)}
</PaletteConfigForm>
);
}Custom UI example
You can render your own controls while still using frosting's normalization and preview logic:
import { PaletteConfigForm, SCHEME_KINDS } from "@moonfloss/frosting/ui-control";
export function CustomPaletteBuilder() {
return (
<PaletteConfigForm
initialValues={{
inputMode: "scheme",
schemeKind: "triad",
cvdVariants: ["deuteranopia"],
}}
onSubmit={({ values, input, options, palette }) => {
console.log(values);
console.log(input, options);
console.log(palette);
}}
>
{(form) => (
<form onSubmit={form.handleSubmit}>
<select
value={form.fields.inputMode.value}
onChange={(event) =>
form.fields.inputMode.onChange(
event.target.value as "brand" | "scheme",
)
}
>
<option value="brand">Brand colors</option>
<option value="scheme">Scheme</option>
</select>
{form.values.inputMode === "brand" ? (
<>
{form.brandColors.fields.map((field) => (
<input
key={field.index}
value={field.value}
onChange={(event) =>
field.onTextChange(event.target.value)
}
placeholder="#000000"
/>
))}
<button
type="button"
onClick={() => form.brandColors.add()}
disabled={!form.brandColors.canAdd}
>
Add color
</button>
</>
) : (
<>
<select
value={form.fields.schemeKind.value}
onChange={(event) =>
form.fields.schemeKind.onChange(
event.target.value as (typeof SCHEME_KINDS)[number],
)
}
>
{SCHEME_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
<input
value={form.fields.schemeBase.value}
onChange={(event) =>
form.fields.schemeBase.onTextChange(event.target.value)
}
placeholder="#000000"
/>
</>
)}
{!form.isValid && <p>Enter a valid brand color or scheme base.</p>}
{form.palette && <pre>{JSON.stringify(form.paletteInput, null, 2)}</pre>}
<button type="submit" disabled={!form.isValid}>
Submit
</button>
</form>
)}
</PaletteConfigForm>
);
}Helper exports
If you want to build your own state layer instead of using the wrapper, @moonfloss/frosting/ui-control also exports:
DEFAULT_PALETTE_CONFIG_FORM_VALUESmergePaletteConfigFormValues()valuesToPaletteInput()valuesToPaletteOptions()parseHex()toBrandArray()SCHEME_KINDSCVD_OPTIONS
These are useful if you want to keep state in another form library but still reuse frosting's normalization rules.
The repo demo under src/ui-control/demo shows the wrapper rendered with semantic-ui-react.
Basic usage
Explicit brand colors
generatePalette({
brand: ["#7C3AED"],
});1–4 colors allowed. Order matters. First color = primary.
User-provided brand colors are never modified.
Scheme-derived palettes (opt-in)
generatePalette({
scheme: {
base: "#7C3AED",
kind: "triad",
},
});Supported schemes:
- monochromatic
- adjacent
- adjacent+complementary
- triad
- tetrad
This only derives the brand anchors. Ramp generation stays consistent.
Per-mode overrides
generatePalette({
brand: {
light: ["#7C3AED"],
dark: ["#A78BFA"],
},
background: {
light: "#FFFFFF",
dark: "#0B0B0C",
},
});If background/foreground aren’t provided, frosting derives them from the primary brand color with a subtle tint.
Color blindness variants
generatePalette(input, {
cvdVariants: ["deuteranopia"],
});Supported:
- protanopia
- deuteranopia
- tritanopia
- all
Pass one or more types in cvdVariants; the palette is simulated (Brettel-style) for each type and emitted as config.variants[type] (e.g. config.variants.deuteranopia), with the same structure as modes (light/dark ramps and semantic tokens).
Theme mapping utility
Use the mapper when you need generatePalette() output shaped to your own contract, but do not want to fork or replace frosting's base PaletteConfig.
TypeScript API
import {
generatePalette,
mapPaletteToTheme,
type ThemeMappingConfig,
} from "@moonfloss/frosting";
type AppTheme = {
light: {
surface: { page: string };
text: { primary: string };
};
dark: {
surface: { page: string };
text: { primary: string };
};
};
const palette = generatePalette({ brand: ["#7C3AED"] });
const config: ThemeMappingConfig<AppTheme> = {
template: {
light: { surface: { page: "" }, text: { primary: "" } },
dark: { surface: { page: "" }, text: { primary: "" } },
},
mappings: {
"light.text.primary": "light.foreground",
"dark.text.primary": "dark.foreground",
},
fuzzy: {
derivedAliases: true,
},
};
const { theme, diagnostics } = mapPaletteToTheme(palette, config);
console.log(theme);
console.log(diagnostics.unresolved);Storefront-style TypeScript example
import {
generatePalette,
mapPaletteToTheme,
type ThemeMappingConfig,
} from "@moonfloss/frosting";
type StorefrontThemeRamp = {
50?: string;
100?: string;
200?: string;
300?: string;
400?: string;
500?: string;
600?: string;
700?: string;
800?: string;
900?: string;
};
type StorefrontThemeMode = {
brand?: StorefrontThemeRamp;
accent?: { primary?: string; secondary?: string; contrast?: string };
text?: { primary?: string; muted?: string; inverse?: string; link?: string };
surface?: {
page?: string;
card?: string;
subtle?: string;
elevated?: string;
inverse?: string;
};
border?: { default?: string; muted?: string; strong?: string; inverse?: string };
status?: { success?: string; warning?: string; error?: string; info?: string };
};
type StorefrontTheme = {
version: 1;
light: StorefrontThemeMode;
dark?: StorefrontThemeMode;
};
const palette = generatePalette({ brand: ["#7C3AED"] });
const config: ThemeMappingConfig<StorefrontTheme> = {
template: {
version: 1,
light: {
brand: { 50: "", 100: "", 200: "", 300: "", 400: "", 500: "", 600: "", 700: "", 800: "", 900: "" },
accent: { primary: "", secondary: "", contrast: "" },
text: { primary: "", muted: "", inverse: "", link: "" },
surface: { page: "", card: "", subtle: "", elevated: "", inverse: "" },
border: { default: "", muted: "", strong: "", inverse: "" },
status: { success: "", warning: "", error: "", info: "" },
},
dark: {
brand: { 50: "", 100: "", 200: "", 300: "", 400: "", 500: "", 600: "", 700: "", 800: "", 900: "" },
accent: { primary: "", secondary: "", contrast: "" },
text: { primary: "", muted: "", inverse: "", link: "" },
surface: { page: "", card: "", subtle: "", elevated: "", inverse: "" },
border: { default: "", muted: "", strong: "", inverse: "" },
status: { success: "", warning: "", error: "", info: "" },
},
},
mappings: {
"light.brand.50": "light.brand1.50",
"light.brand.100": "light.brand1.100",
"light.brand.200": "light.brand1.200",
"light.brand.300": "light.brand1.300",
"light.brand.400": "light.brand1.400",
"light.brand.500": "light.brand1.500",
"light.brand.600": "light.brand1.600",
"light.brand.700": "light.brand1.700",
"light.brand.800": "light.brand1.800",
"light.brand.900": "light.brand1.900",
"dark.brand.50": "dark.brand1.50",
"dark.brand.100": "dark.brand1.100",
"dark.brand.200": "dark.brand1.200",
"dark.brand.300": "dark.brand1.300",
"dark.brand.400": "dark.brand1.400",
"dark.brand.500": "dark.brand1.500",
"dark.brand.600": "dark.brand1.600",
"dark.brand.700": "dark.brand1.700",
"dark.brand.800": "dark.brand1.800",
"dark.brand.900": "dark.brand1.900",
},
fuzzy: {
derivedAliases: true,
},
requiredPaths: ["light.surface.page", "dark.surface.page"],
};
const { theme, diagnostics } = mapPaletteToTheme(palette, config);
if (diagnostics.missingRequired.length) {
throw new Error(`Missing required paths: ${diagnostics.missingRequired.join(", ")}`);
}Mapper configuration guidance
template: nested output shape to fill; fields with"",null, orundefinedare treated as mapping placeholders.mappings: explicittargetPath -> sourceTokenoverrides for ambiguous or custom behavior.fuzzy.derivedAliases:trueenables friendly aliases likelight.surface.pageanddark.text.muted;falserestricts matching to base semantic/ramp tokens.requiredPaths: target paths that must resolve; unresolved required paths are returned indiagnostics.missingRequired.diagnostics: inspectresolved,unresolved, andambiguousentries to tune your mapping config.
Common source tokens for mappings
Use these source token names in your mappings object.
| Category | Examples |
| --- | --- |
| Semantic tokens | light.background, light.foreground, light.primary, light.secondary, light.accent, dark.background, dark.foreground, dark.primary |
| Ramp steps | light.brand1.500, light.brand2.500, light.neutral.100, light.neutral.900, dark.brand1.500, dark.neutral.800 |
| Alias tokens (fuzzy.derivedAliases: true) | light.surface.page, light.surface.card, light.text.primary, light.text.muted, light.text.link, light.accent.primary, light.status.warning, dark.surface.page, dark.text.primary, dark.status.error |
| Literal color override | #ff0000 (you can map a target path directly to a fixed hex value) |
If you are unsure what to map first, start with semantic tokens (light.background, light.foreground, light.primary) and add explicit ramp mappings only where you need exact scale control.
CLI mapping
Use map:path (or m:path) to apply the same mapper config file in the CLI.
frosting config:input.json map:theme-map.json > theme.jsonMapper config example (theme-map.json):
{
"template": {
"light": {
"surface": { "page": "" }
}
},
"mappings": {
"light.surface.page": "light.background"
},
"fuzzy": {
"derivedAliases": true
},
"requiredPaths": ["light.surface.page"],
"failOnUnresolved": true
}When failOnUnresolved is true, the CLI exits with an error if any unresolved mapped path remains.
Existing Tailwind, Chakra, CLI defaults, and UI-control integrations continue to use the regular PaletteConfig shape unless you explicitly map output.
Tailwind
The @moonfloss/frosting/tailwind export provides Tailwind-themed output from a PaletteConfig: CSS custom properties, a theme config object, and a Tailwind plugin. All tokens use prefixed names: {mode}-{variant}-{token} (e.g. light-default-background, dark-protanopia-primary).
CSS custom properties
import { generatePalette } from "@moonfloss/frosting";
import { generateCssVars } from "@moonfloss/frosting/tailwind";
const palette = generatePalette({ brand: ["#7C3AED"] });
const css = generateCssVars(palette);
// Write to a file and import in your app, or use the plugin (below)Tailwind theme config
import { generateTailwindTheme } from "@moonfloss/frosting/tailwind";
const theme = generateTailwindTheme(palette);
// theme.extend in tailwind.config:
export default {
theme: { extend: theme },
};Tailwind plugin (CSS vars + theme in one go)
import { frostingPlugin } from "@moonfloss/frosting/tailwind";
export default {
plugins: [frostingPlugin(palette)],
};Then use utilities like bg-light-default-background, text-dark-default-primary, or ramp shades like bg-light-default-brand1-500.
Chakra UI
The @moonfloss/frosting/chakra export generates a Chakra UI v2–compatible theme from a PaletteConfig: color scales (brand1, brand2, neutral, etc.) and semantic tokens with built-in light/dark mode. Use it with extendTheme and <ChakraProvider>.
Basic setup
import { generatePalette } from "@moonfloss/frosting";
import { generateChakraTheme } from "@moonfloss/frosting/chakra";
import { extendTheme, ChakraProvider } from "@chakra-ui/react";
const palette = generatePalette({ brand: ["#7C3AED", "#F59E0B"] });
const theme = extendTheme(generateChakraTheme(palette));
<ChakraProvider theme={theme}>
<App />
</ChakraProvider>The theme adds:
- colors — Ramps as scales:
brand1,brand2,neutral(e.g.brand1.500). Use them withcolorScheme="brand1"on components likeButton. - semanticTokens.colors — Tokens such as
background,foreground,primary,primary-foreground,card,muted,border,ring, etc., withdefaultand_darkso Chakra’s color mode works automatically.
Options
generateChakraTheme(palette, {
variant: "protanopia", // CVD variant (default: "default")
includeRamps: true, // include color scales (default: true)
includeSemantic: true, // include semantic tokens (default: true)
prefix: "frosting", // prefix keys, e.g. frosting-brand1
});CVD variants
For color-vision-deficiency variants, generate a theme per variant and pass that theme to ChakraProvider. When the user picks a variant, swap the theme.
const palette = generatePalette(
{ brand: ["#7C3AED"] },
{ cvdVariants: ["protanopia", "deuteranopia", "tritanopia"] },
);
const defaultTheme = extendTheme(generateChakraTheme(palette));
const cvdTheme = extendTheme(generateChakraTheme(palette, { variant: "protanopia" }));
// Use defaultTheme or cvdTheme in ChakraProvider depending on user choice.Color scheme helpers
import { getChakraColorSchemes } from "@moonfloss/frosting/chakra";
const schemes = getChakraColorSchemes(palette);
// { brand1: "brand1", brand2: "brand2", neutral: "neutral" }
<Button colorScheme={schemes.brand1}>Primary</Button>With prefix: "frosting", the values are "frosting-brand1", etc.
Demo
From the repo root, run the Chakra demo:
npm run demo:chakraOutput shape (simplified)
{
version,
inputs: {...},
modes: {
light: {
ramps: {
brand1,
brand2?,
neutral,
gray
},
semantic: {...},
meta: {...}
},
dark: {...}
},
variants?: {...}
}neutral and gray map to the same ramp by design.
