Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-css-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tenphi/glaze': minor
---

Add CSS custom property export method for themes and palettes
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Glaze generates robust **light**, **dark**, and **high-contrast** color schemes
- **Light + Dark + High-Contrast** — all schemes from one definition
- **Per-color hue override** — absolute or relative hue shifts within a theme
- **Multi-format output** — `okhsl`, `rgb`, `hsl`, `oklch`
- **CSS custom properties export** — ready-to-use `--var: value;` declarations per scheme
- **Import/Export** — serialize and restore theme configurations
- **Create from hex/RGB** — start from an existing brand color
- **Zero dependencies** — pure math, runs anywhere (Node.js, browser, edge)
Expand Down Expand Up @@ -306,7 +307,7 @@ theme.tokens({ format: 'hsl' }); // → 'hsl(270.5, 45.2%, 95.8%)'
theme.tokens({ format: 'oklch' }); // → 'oklch(96.5% 0.0123 280.0)'
```

The `format` option works on all export methods: `theme.tokens()`, `theme.json()`, `palette.tokens()`, `palette.json()`, and standalone `glaze.color().token()` / `.json()`.
The `format` option works on all export methods: `theme.tokens()`, `theme.json()`, `theme.css()`, `palette.tokens()`, `palette.json()`, `palette.css()`, and standalone `glaze.color().token()` / `.json()`.

Available formats:

Expand Down Expand Up @@ -433,6 +434,53 @@ const data = palette.json({ prefix: true });
// }
```

### CSS Export

Export as CSS custom property declarations, grouped by scheme variant. Each variant is a string of `--name-color: value;` lines that you can wrap in your own selectors and media queries.

```ts
const css = theme.css();
// css.light → "--surface-color: rgb(...);\n--text-color: rgb(...);"
// css.dark → "--surface-color: rgb(...);\n--text-color: rgb(...);"
// css.lightContrast → "--surface-color: rgb(...);\n--text-color: rgb(...);"
// css.darkContrast → "--surface-color: rgb(...);\n--text-color: rgb(...);"
```

Use in a stylesheet:

```ts
const css = palette.css({ prefix: true });

const stylesheet = `
:root { ${css.light} }
@media (prefers-color-scheme: dark) {
:root { ${css.dark} }
}
`;
```

Options:

| Option | Default | Description |
|---|---|---|
| `format` | `'rgb'` | Color format (`'rgb'`, `'hsl'`, `'okhsl'`, `'oklch'`) |
| `suffix` | `'-color'` | Suffix appended to each CSS property name |
| `prefix` | — | (palette only) Same prefix behavior as `tokens()` |

```ts
// Custom suffix
theme.css({ suffix: '' });
// → "--surface: rgb(...);"

// Custom format
theme.css({ format: 'hsl' });
// → "--surface-color: hsl(...);"

// Palette with prefix
palette.css({ prefix: true });
// → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
```

## Output Modes

Control which scheme variants appear in exports:
Expand All @@ -450,7 +498,7 @@ palette.tokens({ modes: { dark: true, highContrast: true } });

Resolution priority (highest first):

1. `tokens({ modes })` / `json({ modes })` — per-call override
1. `tokens({ modes })` / `json({ modes })` / `css({ ... })` — per-call override
2. `glaze.configure({ modes })` — global config
3. Built-in default: `{ dark: true, highContrast: false }`

Expand Down Expand Up @@ -567,6 +615,11 @@ const tokens = palette.tokens({ prefix: true });
// Export as RGB for broader CSS compatibility
const rgbTokens = palette.tokens({ prefix: true, format: 'rgb' });

// Export as CSS custom properties (rgb format by default)
const css = palette.css({ prefix: true });
// css.light → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
// css.dark → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"

// Save and restore a theme
const snapshot = primary.export();
const restored = glaze.from(snapshot);
Expand Down Expand Up @@ -605,6 +658,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
| `theme.resolve()` | Resolve all colors |
| `theme.tokens(options?)` | Export as token map |
| `theme.json(options?)` | Export as plain JSON |
| `theme.css(options?)` | Export as CSS custom property declarations |

### Global Configuration

Expand Down
102 changes: 102 additions & 0 deletions src/glaze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,4 +935,106 @@ describe('glaze', () => {
expect(surfaceToken['']).toMatch(/^okhsl\(/);
});
});

describe('css export', () => {
it('outputs CSS custom properties with default options (rgb format, -color suffix)', () => {
const theme = glaze(280, 80);
theme.colors({
surface: { lightness: 97, saturation: 0.75 },
text: { base: 'surface', lightness: '-52', contrast: 'AAA' },
});

const css = theme.css();

// All four variants should be present
expect(css.light).toBeDefined();
expect(css.dark).toBeDefined();
expect(css.lightContrast).toBeDefined();
expect(css.darkContrast).toBeDefined();

// Should use rgb format by default
expect(css.light).toMatch(/^--surface-color: rgb\(/);
expect(css.light).toMatch(/--text-color: rgb\(/);

// Each variant should have two lines (one per color)
expect(css.light.split('\n')).toHaveLength(2);
expect(css.dark.split('\n')).toHaveLength(2);

// Lines should end with semicolons
for (const line of css.light.split('\n')) {
expect(line).toMatch(/;$/);
}
});

it('respects custom format option', () => {
const theme = glaze(280, 80);
theme.colors({ surface: { lightness: 97 } });

const css = theme.css({ format: 'okhsl' });
expect(css.light).toMatch(/--surface-color: okhsl\(/);

const cssHsl = theme.css({ format: 'hsl' });
expect(cssHsl.light).toMatch(/--surface-color: hsl\(/);

const cssOklch = theme.css({ format: 'oklch' });
expect(cssOklch.light).toMatch(/--surface-color: oklch\(/);
});

it('respects custom suffix option', () => {
const theme = glaze(280, 80);
theme.colors({ surface: { lightness: 97 } });

const css = theme.css({ suffix: '' });
expect(css.light).toMatch(/^--surface: rgb\(/);

const cssBg = theme.css({ suffix: '-bg' });
expect(cssBg.light).toMatch(/^--surface-bg: rgb\(/);
});

it('produces different values for light and dark variants', () => {
const theme = glaze(280, 80);
theme.colors({ surface: { lightness: 97 } });

const css = theme.css();
expect(css.light).not.toBe(css.dark);
});

it('works with palette and prefix', () => {
const primary = glaze(280, 80);
primary.colors({ surface: { lightness: 97 } });

const danger = primary.extend({ hue: 23 });

const palette = glaze.palette({ primary, danger });
const css = palette.css({ prefix: true });

expect(css.light).toMatch(/--primary-surface-color: rgb\(/);
expect(css.light).toMatch(/--danger-surface-color: rgb\(/);
});

it('works with palette and custom prefix map', () => {
const primary = glaze(280, 80);
primary.colors({ surface: { lightness: 97 } });

const danger = primary.extend({ hue: 23 });

const palette = glaze.palette({ primary, danger });
const css = palette.css({ prefix: { primary: 'p-', danger: 'd-' } });

expect(css.light).toMatch(/--p-surface-color: rgb\(/);
expect(css.light).toMatch(/--d-surface-color: rgb\(/);
});

it('works with palette without prefix', () => {
const primary = glaze(280, 80);
primary.colors({ surface: { lightness: 97 } });

const palette = glaze.palette({ primary });
const css = palette.css();

expect(css.light).toMatch(/--surface-color: rgb\(/);
// No prefix
expect(css.light).not.toMatch(/--primary-/);
});
});
});
95 changes: 95 additions & 0 deletions src/glaze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import type {
GlazeExtendOptions,
GlazeTokenOptions,
GlazeJsonOptions,
GlazeCssOptions,
GlazeCssResult,
GlazeColorInput,
GlazeColorToken,
} from './types';
Expand Down Expand Up @@ -608,6 +610,39 @@ function buildJsonMap(
return result;
}

function buildCssMap(
resolved: Map<string, ResolvedColor>,
prefix: string,
suffix: string,
format: GlazeColorFormat,
): GlazeCssResult {
const lines: Record<keyof GlazeCssResult, string[]> = {
light: [],
dark: [],
lightContrast: [],
darkContrast: [],
};

for (const [name, color] of resolved) {
const prop = `--${prefix}${name}${suffix}`;
lines.light.push(`${prop}: ${formatVariant(color.light, format)};`);
lines.dark.push(`${prop}: ${formatVariant(color.dark, format)};`);
lines.lightContrast.push(
`${prop}: ${formatVariant(color.lightContrast, format)};`,
);
lines.darkContrast.push(
`${prop}: ${formatVariant(color.darkContrast, format)};`,
);
}

return {
light: lines.light.join('\n'),
dark: lines.dark.join('\n'),
lightContrast: lines.lightContrast.join('\n'),
darkContrast: lines.darkContrast.join('\n'),
};
}

// ============================================================================
// Theme implementation
// ============================================================================
Expand Down Expand Up @@ -697,6 +732,16 @@ function createTheme(
const modes = resolveModes(options?.modes);
return buildJsonMap(resolved, modes, options?.format);
},

css(options?: GlazeCssOptions): GlazeCssResult {
const resolved = resolveAllColors(hue, saturation, colorDefs);
return buildCssMap(
resolved,
'',
options?.suffix ?? '-color',
options?.format ?? 'rgb',
);
},
} as GlazeTheme;

return theme;
Expand Down Expand Up @@ -764,6 +809,56 @@ function createPalette(themes: PaletteInput) {

return result;
},

css(
options?: GlazeCssOptions & {
prefix?: boolean | Record<string, string>;
},
): GlazeCssResult {
const suffix = options?.suffix ?? '-color';
const format = options?.format ?? 'rgb';

const allLines: Record<keyof GlazeCssResult, string[]> = {
light: [],
dark: [],
lightContrast: [],
darkContrast: [],
};

for (const [themeName, theme] of Object.entries(themes)) {
const resolved = theme.resolve();

let prefix = '';
if (options?.prefix === true) {
prefix = `${themeName}-`;
} else if (
typeof options?.prefix === 'object' &&
options.prefix !== null
) {
prefix = options.prefix[themeName] ?? `${themeName}-`;
}

const css = buildCssMap(resolved, prefix, suffix, format);

for (const key of [
'light',
'dark',
'lightContrast',
'darkContrast',
] as const) {
if (css[key]) {
allLines[key].push(css[key]);
}
}
}

return {
light: allLines.light.join('\n'),
dark: allLines.dark.join('\n'),
lightContrast: allLines.lightContrast.join('\n'),
darkContrast: allLines.darkContrast.join('\n'),
};
},
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type {
GlazeExtendOptions,
GlazeTokenOptions,
GlazeJsonOptions,
GlazeCssOptions,
GlazeCssResult,
GlazeColorInput,
GlazeColorToken,
GlazePalette,
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ export interface GlazeTheme {

/** Export as plain JSON. */
json(options?: GlazeJsonOptions): Record<string, Record<string, string>>;

/** Export as CSS custom property declarations. */
css(options?: GlazeCssOptions): GlazeCssResult;
}

export interface GlazeExtendOptions {
Expand Down Expand Up @@ -226,6 +229,21 @@ export interface GlazeJsonOptions {
format?: GlazeColorFormat;
}

export interface GlazeCssOptions {
/** Output color format. Default: 'rgb'. */
format?: GlazeColorFormat;
/** Suffix appended to each CSS custom property name. Default: '-color'. */
suffix?: string;
}

/** CSS custom property declarations grouped by scheme variant. */
export interface GlazeCssResult {
light: string;
dark: string;
lightContrast: string;
darkContrast: string;
}

export interface GlazePalette {
/** Export all themes as a combined token map. */
tokens(options?: GlazeTokenOptions): Record<string, Record<string, string>>;
Expand All @@ -236,4 +254,11 @@ export interface GlazePalette {
prefix?: boolean | Record<string, string>;
},
): Record<string, Record<string, Record<string, string>>>;

/** Export all themes as CSS custom property declarations. */
css(
options?: GlazeCssOptions & {
prefix?: boolean | Record<string, string>;
},
): GlazeCssResult;
}