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/mean-taxis-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@layerstack/tailwind': patch
---

fix: Split theme generation CLI from runtime theme utils
7 changes: 4 additions & 3 deletions packages/tailwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"scripts": {
"dev": "rimraf dist && tsc -p tsconfig.build.json --watch",
"build": "rimraf dist && tsc -p tsconfig.build.json && pnpm build:css && cp -r ./src/lib/css dist",
"build:css": "tsc --noEmit && tsx ./src/lib/cli.ts",
"build:css": "tsc --noEmit && tsx ./src/lib/cli/index.ts",
"preview": "vite preview",
"package": "svelte-package",
"prepublishOnly": "svelte-package",
Expand All @@ -24,13 +24,14 @@
"@types/culori": "^2.1.1",
"@types/d3-array": "^3.2.1",
"@types/lodash-es": "^4.17.12",
"daisyui": "^4.12.24",
"@types/node": "^24.0.1",
"culori": "^4.0.1",
"daisyui": "^4.12.24",
"prettier": "^3.5.3",
"rimraf": "6.0.1",
"tailwindcss": "^4.1.5",
"tslib": "^2.8.1",
"tsx": "^4.19.4",
"tailwindcss": "^4.1.5",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { format } from 'prettier';
import { entries } from '@layerstack/utils';
import { mapKeys } from '@layerstack/utils/object';

import { colorNames, themeStylesString, type Colors, type SupportedColorSpace } from './theme.js';
import { colorNames, themeStylesString, type Colors, type SupportedColorSpace } from './utils.js';
import { themes as daisyThemes } from './daisy.js';
import { themes as skeletonThemes } from './skeleton.js';

Expand Down
194 changes: 194 additions & 0 deletions packages/tailwind/src/lib/cli/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
rgb,
hsl,
oklch,
clampRgb,
interpolate,
wcagContrast,
formatCss,
type Color,
type Hsl,
type Oklch,
type Rgb,
} from 'culori';
import { entries, fromEntries, keys } from '@layerstack/utils';
import { colors, semanticColors, shades, stateColors } from '../theme.js';

export type SupportedColorSpace = 'rgb' | 'hsl' | 'oklch';
export type Colors = Record<string, string>;

export const colorNames = [
// Semantic & State colors (ex. `priamry`, 'primary-content`, 'primary-100`, ...)
...colors.flatMap((color) => [
color, // default
`${color}-content`, // text/content
...shades.map((shade) => `${color}-${shade}`),
]),

// Surfaces
'surface-100',
'surface-200',
'surface-300',
'surface-content',
];

/**
* Generate missing theme colors (if needed), convert names to CSS variables and to a common color space (hsl, oklch, etc)
*/
export function processThemeColors(colors: Colors, colorSpace: SupportedColorSpace) {
// TODO: make all semanatic colors optional as well

// Generate optional semanatic colors
colors['neutral'] ??= colors['neutral-500'] ?? 'oklch(.355192 .032071 262.988584)';

// Generate optional state colors
colors['info'] ??= colors['info-500'] ?? 'oklch(0.7206 0.191 231.6)';
colors['success'] ??= colors['success-500'] ?? 'oklch(64.8% 0.150 160)';
colors['warning'] ??= colors['warning-500'] ?? 'oklch(0.8471 0.199 83.87)';
colors['danger'] ??= colors['danger-500'] ?? 'oklch(0.7176 0.221 22.18)';

// Generate optional content colors
for (const color of [...semanticColors, ...stateColors]) {
// Add `primary` from `primary-500` if not defined in theme (ex. Skeleton)
colors[color] ??= colors[`${color}-500`];
colors[`${color}-content`] ??= foregroundColor(colors[color]) as string;

// Generate color shades (ex. `primary-500`) if not defined. Useful for Daisy but not Skeleton themes, for example
for (const shade of shades) {
const shadeColorName = `${color}-${shade}`;
if (!(shadeColorName in colors)) {
// Find the next shade above (shade < 500) or below (shade > 500) and use as reference, if available
const referenceShade =
keys(colors)
.map((key) => {
const [c, s] = String(key).split('-');
return [c, Number(s)] as [string, number];
})
.find(([c, s]) => c === color && (s < 500 ? s > shade : s < shade))?.[1] ?? 500;
const referenceColor = colors[`${color}-${referenceShade}`] ?? colors[color];

if (shade < 500) {
colors[shadeColorName] ??= lightenColor(
referenceColor,
(referenceShade - shade) / 1000
) as string; // 100 == 0.1
} else if (shade > 500) {
colors[shadeColorName] ??= darkenColor(
colors[color],
(shade - referenceShade) / 1000
) as string; // 100 == 0.1
} else {
colors[shadeColorName] ??= colors[color] as string;
}
}
}
}

// Generate optional surface colors
colors['surface-100'] ??= 'oklch(100 0 0)';
colors['surface-200'] ??= darkenColor(colors['surface-100'], 0.07) as string;
colors['surface-300'] ??= darkenColor(colors['surface-200'], 0.07) as string;
colors['surface-content'] ??= foregroundColor(colors['surface-100']) as string;

// Add `color-scheme: "dark"` for `dark` theme (if not set)
colors['color-scheme'] ??= isDark(colors['surface-content']) ? 'light' : 'dark';

const result = fromEntries(
entries(colors).map(([name, value]) => {
if (colorNames.includes(String(name))) {
// Convert each color to common colorspace and add variable
return [`--color-${name}`, convertColor(value, colorSpace)];
} else {
// Additional properties such as `color-scheme` or CSS variable
return [name, value];
}
})
);

return result;
}

function round(value: number, decimals: number) {
if (value) {
return Number(value.toFixed(decimals));
} else {
return 0;
}
}

function isDark(color: Color | string) {
try {
if (wcagContrast(color, 'black') < wcagContrast(color, 'white')) {
return true;
}
return false;
} catch (e) {
return false;
}
}

/** Lighten or darken color based on contrast of input */
function foregroundColor(color: Color | string, percentage = 0.8) {
try {
return isDark(color) ? lightenColor(color, percentage) : darkenColor(color, percentage);
} catch (e) {
// console.error('Unable to generate foreground color', color);
}
}

function lightenColor(color: Color | string, percentage: number) {
try {
return formatCss(interpolate([color, 'white'], 'oklch')(percentage));
} catch (e) {
// console.error('Unable to generate lighten color', color);
}
}

function darkenColor(color: Color | string, percentage: number) {
try {
return formatCss(interpolate([color, 'black'], 'oklch')(percentage));
} catch (e) {
// console.error('Unable to generate darken color', color);
}
}

/**
* Convert color to space separated components string
*/
export function convertColor(color: Color | string, colorSpace: SupportedColorSpace, decimals = 4) {
try {
if (colorSpace === 'rgb') {
const computedColor = typeof color === 'string' ? rgb(color) : (color as Rgb);
if (computedColor) {
const { r, g, b } = computedColor;
return `rgb(${round(r * 255, decimals)} ${round(g * 255, decimals)} ${round(b * 255, decimals)})`;
}
} else if (colorSpace === 'hsl') {
const computedColor = typeof color === 'string' ? hsl(clampRgb(color)) : (color as Hsl);
if (computedColor) {
const { h, s, l } = computedColor;
return `hsl(${round(h ?? 0, decimals)} ${round(s * 100, decimals)}% ${round(l * 100, decimals)}%)`;
}
} else if (colorSpace === 'oklch') {
const computedColor = typeof color === 'string' ? oklch(clampRgb(color)) : (color as Oklch);
if (computedColor) {
const { l, c, h } = computedColor;
return `oklch(${round(l, decimals)} ${round(c, decimals)} ${round(h ?? 0, decimals)})`;
}
}
} catch (e) {
// console.error('Unable to convert color object to string', color);
}
}

/**
* Process theme to style variables
*/
export function themeStylesString(colors: Colors, colorSpace: SupportedColorSpace) {
const styleProperties = processThemeColors(colors, colorSpace);
return entries(styleProperties)
.map(([key, value]) => {
return `${key}: ${value};`;
})
.join('\n');
}
Loading