Skip to content

Commit

Permalink
Add colorFormat option
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Sep 7, 2023
1 parent d6cb862 commit c97edd6
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 152 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-tips-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cobalt-ui/plugin-css': minor
---

Add colorFormat option
5 changes: 5 additions & 0 deletions .changeset/proud-foxes-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cobalt-ui/plugin-sass': minor
---

Add colorFormat option
2 changes: 2 additions & 0 deletions docs/src/pages/docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export default {
};
```

_Note: individual plugins may override these settings per-platform. E.g. you may want a different color format for CSS/web than you do for native code._

## Syncing with Figma

You can sync tokens with Figma by using the [Tokens Studio for Figma](/docs/guides/tokens-studio) plugin.
Expand Down
21 changes: 21 additions & 0 deletions packages/plugin-css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export default {
prefix: '',
/** enable P3 support? */
p3: true,
/** normalize all colors */
colorFormat: 'hex',
}),
],
};
Expand Down Expand Up @@ -238,6 +240,25 @@ That will generate the following:

[Learn more about modes](https://cobalt-ui.pages.dev/docs/guides/modes/)

### Color Format

By specifying a `colorFormat`, you can transform all your colors to [any browser-supported colorspace](https://www.w3.org/TR/css-color-4/). Any of the following colorspaces are accepted:

- [hex](https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color) (default)
- [rgb](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb)
- [hsl](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl)
- [hwb](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hwb)
- [lab](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lab)
- [lch](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lch)
- [oklab](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklab)
- [oklch](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch)
- [p3](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color)
- [srgb-linear](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)
- [xyz-d50](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)
- [xyz-d65](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)

If you are unfamiliar with these colorspaces, the default `hex` value is best for most users (though [you should use OKLCH to define your colors](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl)).

### Transform

Inside plugin options, you can specify an optional `transform()` function.
Expand Down
115 changes: 91 additions & 24 deletions packages/plugin-css/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
ResolvedConfig,
} from '@cobalt-ui/core';
import {indent, isAlias, kebabinate, FG_YELLOW, RESET} from '@cobalt-ui/utils';
import {converter, formatCss} from 'culori';
import {clampChroma, converter, formatCss, formatHex, formatHex8, formatHsl, formatRgb, parse as parseColor} from 'culori';
import {encode, formatFontNames, isTokenMatch} from './util.js';

const CSS_VAR_RE = /^var\(--[^)]+\)$/;
Expand Down Expand Up @@ -55,10 +55,22 @@ export interface Options {
prefix?: string;
/** enable P3 color enhancement? (default: true) */
p3?: boolean;
/** normalize all color outputs to format (default: "hex") or specify "none" to keep as-is */
colorFormat?: 'none' | 'hex' | 'rgb' | 'hsl' | 'hwb' | 'srgb-linear' | 'p3' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'xyz-d50' | 'xyz-d65';
}

/** ⚠️ Important! We do NOT want to parse as P3. We want to parse as sRGB, then expand 1:1 to P3. @see https://webkit.org/blog/10042/wide-gamut-color-in-css-with-display-p3/ */
const rgb = converter('rgb');
const toHSL = converter('hsl');
const toHWB = converter('hwb');
const toLab = converter('lab');
const toLch = converter('lch');
const toOklab = converter('oklab');
const toOklch = converter('oklch');
const toP3 = converter('p3');
const toRGB = converter('rgb');
const toRGBLinear = converter('lrgb');
const toXYZ50 = converter('xyz50');
const toXYZ65 = converter('xyz65');

export default function pluginCSS(options?: Options): Plugin {
let config: ResolvedConfig;
Expand Down Expand Up @@ -89,9 +101,9 @@ export default function pluginCSS(options?: Options): Plugin {
if (!matches || !matches.length) continue;
let newVal = line;
for (const c of matches) {
const parsed = rgb(c);
if (!parsed) throw new Error(`invalid color "${c}"`);
newVal = newVal.replace(c, formatCss({...parsed, mode: 'p3'}));
const rgb = toRGB(c);
if (!rgb) throw new Error(`invalid color "${c}"`);
newVal = newVal.replace(c, formatCss({...rgb, mode: 'p3'}));
hasValidColors = true; // keep track of whether or not actual colors have been generated (we also generate non-color output, so checking for output.length won’t work)
}
output.push(newVal);
Expand All @@ -112,9 +124,10 @@ export default function pluginCSS(options?: Options): Plugin {
const tokenVals: {[id: string]: any} = {};
const modeVals: {[selector: string]: {[id: string]: any}} = {};
const selectors: string[] = [];
const colorFormat = options?.colorFormat ?? 'hex';
const customTransform = typeof options?.transform === 'function' ? options.transform : undefined;
for (const token of tokens) {
let value = (customTransform && customTransform(token)) || defaultTransformer(token, {prefix});
let value = (customTransform && customTransform(token)) || defaultTransformer(token, {colorFormat, prefix});
switch (token.$type) {
case 'link': {
if (options?.embedFiles) value = encode(value as string, config.outDir);
Expand Down Expand Up @@ -170,7 +183,7 @@ export default function pluginCSS(options?: Options): Plugin {
for (const selector of modeSelector.selectors) {
if (!selectors.includes(selector)) selectors.push(selector);
if (!modeVals[selector]) modeVals[selector] = {};
let modeVal = (customTransform && customTransform(token, modeSelector.mode)) || defaultTransformer(token, {prefix, mode: modeSelector.mode});
let modeVal = (customTransform && customTransform(token, modeSelector.mode)) || defaultTransformer(token, {colorFormat, prefix, mode: modeSelector.mode});
switch (token.$type) {
case 'link': {
if (options?.embedFiles) modeVal = encode(modeVal as string, config.outDir);
Expand Down Expand Up @@ -222,7 +235,11 @@ export default function pluginCSS(options?: Options): Plugin {
}

// P3
if (options?.p3 !== false && tokens.some((t) => t.$type === 'color' || t.$type === 'border' || t.$type === 'gradient' || t.$type === 'shadow')) {
if (
options?.p3 !== false &&
(colorFormat === 'hex' || colorFormat === 'rgb' || colorFormat === 'hsl' || colorFormat === 'hwb') && // only transform for the smaller gamuts
tokens.some((t) => t.$type === 'color' || t.$type === 'border' || t.$type === 'gradient' || t.$type === 'shadow')
) {
code.push('');
code.push(indent(`@supports (color: color(display-p3 1 1 1)) {`, 0)); // note: @media (color-gamut: p3) is problematic in most browsers
code.push(...makeP3(makeVars({tokens: tokenVals, indentLv: 1, root: true})));
Expand Down Expand Up @@ -251,8 +268,58 @@ export default function pluginCSS(options?: Options): Plugin {
}

/** transform color */
export function transformColor(value: ParsedColorToken['$value']): string {
return String(value);
export function transformColor(value: ParsedColorToken['$value'], colorFormat: NonNullable<Options['colorFormat']>): string {
if (colorFormat === 'none') {
return String(value);
}

// if this is a flat CSS var, no need to transform
// (note: this may still be present if this is called from a composite token, e.g. border)
if (typeof value === 'string' && CSS_VAR_RE.test(value)) {
return value;
}

const parsed = parseColor(value);
if (!parsed) throw new Error(`invalid color "${value}"`);
switch (colorFormat) {
case 'rgb': {
return formatRgb(clampChroma(toRGB(value)!, 'rgb'));
}
case 'hex': {
const rgb = clampChroma(toRGB(value)!, 'rgb');
return typeof parsed.alpha === 'number' && parsed.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
case 'hsl': {
return formatHsl(clampChroma(toHSL(value)!, 'rgb'));
}
case 'hwb': {
return formatCss(clampChroma(toHWB(value)!, 'rgb'));
}
case 'lab': {
return formatCss(toLab(value)!);
}
case 'lch': {
return formatCss(toLch(value)!);
}
case 'oklab': {
return formatCss(toOklab(value)!);
}
case 'oklch': {
return formatCss(toOklch(value)!);
}
case 'p3': {
return formatCss(toP3(value)!);
}
case 'srgb-linear': {
return formatCss(toRGBLinear(value)!);
}
case 'xyz-d50': {
return formatCss(toXYZ50(value)!);
}
case 'xyz-d65': {
return formatCss(toXYZ65(value)!);
}
}
}
/** transform dimension */
export function transformDimension(value: ParsedDimensionToken['$value']): string {
Expand Down Expand Up @@ -287,16 +354,16 @@ export function transformStrokeStyle(value: ParsedStrokeStyleToken['$value']): s
return String(value);
}
/** transform border */
export function transformBorder(value: ParsedBorderToken['$value']): string {
return [transformDimension(value.width), transformStrokeStyle(value.style), transformColor(value.color)].join(' ');
export function transformBorder(value: ParsedBorderToken['$value'], colorFormat: NonNullable<Options['colorFormat']>): string {
return [transformDimension(value.width), transformStrokeStyle(value.style), transformColor(value.color, colorFormat)].join(' ');
}
/** transform shadow */
export function transformShadow(value: ParsedShadowToken['$value']): string {
return [value.offsetX, value.offsetY, value.blur, value.spread, value.color].join(' ');
export function transformShadow(value: ParsedShadowToken['$value'], colorFormat: NonNullable<Options['colorFormat']>): string {
return [value.offsetX, value.offsetY, value.blur, value.spread, transformColor(value.color, colorFormat)].join(' ');
}
/** transform gradient */
export function transformGradient(value: ParsedGradientToken['$value']): string {
return value.map((g: GradientStop) => `${g.color} ${g.position * 100}%`).join(', ');
export function transformGradient(value: ParsedGradientToken['$value'], colorFormat: NonNullable<Options['colorFormat']>): string {
return value.map((g: GradientStop) => `${transformColor(g.color, colorFormat)} ${g.position * 100}%`).join(', ');
}
/** transform transition */
export function transformTransition(value: ParsedTransitionToken['$value']): string {
Expand All @@ -312,19 +379,19 @@ export function transformTypography(value: ParsedTypographyToken['$value']): Rec
return values;
}

export function defaultTransformer(token: ParsedToken, options?: {mode?: string; prefix?: string}): string | number | ReturnType<typeof transformTypography> {
export function defaultTransformer(token: ParsedToken, options: {colorFormat: NonNullable<Options['colorFormat']>; mode?: string; prefix?: string}): string | number | ReturnType<typeof transformTypography> {
let value = token.$value;
let rawVal = token._original.$value;

// handle modes
if (options?.mode) {
if (options.mode) {
if (!token.$extensions?.mode || !token.$extensions.mode[options.mode]) throw new Error(`Token ${token.id} missing "$extensions.mode.${options.mode}"`);
value = token.$extensions.mode[options.mode]!;
rawVal = ((token._original.$extensions as typeof token.$extensions).mode as typeof token.$extensions.mode)[options?.mode]!; // very cool TS right here
rawVal = ((token._original.$extensions as typeof token.$extensions).mode as typeof token.$extensions.mode)[options.mode]!; // very cool TS right here
}

// handle aliases (both full and partial aliasing within compound tokens)
const refOptions = {prefix: options?.prefix, mode: options?.mode};
const refOptions = {prefix: options.prefix, mode: options.mode};
if (typeof rawVal === 'string' && isAlias(rawVal)) {
value = varRef(rawVal, refOptions);
} else if (rawVal && !Array.isArray(rawVal) && typeof rawVal === 'object') {
Expand All @@ -342,7 +409,7 @@ export function defaultTransformer(token: ParsedToken, options?: {mode?: string;

switch (token.$type) {
case 'color': {
return transformColor(value as typeof token.$value);
return transformColor(value as typeof token.$value, options.colorFormat);
}
case 'dimension': {
return transformDimension(value as typeof token.$value);
Expand Down Expand Up @@ -370,13 +437,13 @@ export function defaultTransformer(token: ParsedToken, options?: {mode?: string;
return transformStrokeStyle(value as typeof token.$value);
}
case 'border': {
return transformBorder(value as typeof token.$value);
return transformBorder(value as typeof token.$value, options.colorFormat);
}
case 'shadow': {
return transformShadow(value as typeof token.$value);
return transformShadow(value as typeof token.$value, options.colorFormat);
}
case 'gradient': {
return transformGradient(value as typeof token.$value);
return transformGradient(value as typeof token.$value, options.colorFormat);
}
case 'transition': {
return transformTransition(value as typeof token.$value);
Expand Down
28 changes: 28 additions & 0 deletions packages/plugin-css/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ describe('@cobalt-ui/plugin-css', () => {
});

describe('options', () => {
test('colorFormat', async () => {
const cwd = new URL(`./color-format/`, import.meta.url);
const tokens = JSON.parse(fs.readFileSync(new URL('./tokens.json', cwd), 'utf8'));
await build(tokens, {
outDir: cwd,
plugins: [
pluginCSS({
filename: 'actual.css',
colorFormat: 'oklch',
modeSelectors: [
{mode: 'light', selectors: ['@media (prefers-color-scheme: light)']},
{mode: 'dark', selectors: ['@media (prefers-color-scheme: dark)']},
{mode: 'light', tokens: ['color.*'], selectors: ['[data-color-theme="light"]']},
{mode: 'dark', tokens: ['color.*'], selectors: ['[data-color-theme="dark"]']},
{mode: 'light-colorblind', tokens: ['color.*'], selectors: ['[data-color-theme="light-colorblind"]']},
{mode: 'light-high-contrast', tokens: ['color.*'], selectors: ['[data-color-theme="light-high-contrast"]']},
{mode: 'dark-dimmed', tokens: ['color.*'], selectors: ['[data-color-theme="dark-dimmed"]']},
{mode: 'dark-high-contrast', tokens: ['color.*'], selectors: ['[data-color-theme="dark-high-contrast"]']},
{mode: 'dark-colorblind', tokens: ['color.*'], selectors: ['[data-color-theme="dark-colorblind"]']},
],
}),
],
color: {},
tokens: [],
});
expect(fs.readFileSync(new URL('./actual.css', cwd), 'utf8')).toMatchFileSnapshot(fileURLToPath(new URL('./want.css', cwd)));
});

test('p3', async () => {
const cwd = new URL(`./p3/`, import.meta.url);
const tokens = JSON.parse(fs.readFileSync(new URL('./tokens.json', cwd), 'utf8'));
Expand Down
27 changes: 27 additions & 0 deletions packages/plugin-css/test/color-format/tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"color": {
"$type": "color",
"blue": {
"$value": "#218bff"
}
},
"border": {
"heavy": {
"$type": "border",
"$value": {"color": "#363636", "width": "3px", "style": "solid"}
}
},
"shadow": {
"simple": {
"$type": "shadow",
"$value": {"offsetX": 0, "offsetY": "4px", "blur": "8px", "spread": 0, "color": "rgb(0, 0, 0, 0.15)"}
}
},
"gradient": {
"$type": "gradient",
"$value": [
{"color": "#218bff", "position": 0},
{"color": "#e85aad", "position": 1}
]
}
}
12 changes: 12 additions & 0 deletions packages/plugin-css/test/color-format/want.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Design Tokens
* Autogenerated from tokens.json.
* DO NOT EDIT!
*/

:root {
--border-heavy: 3px solid oklch(0.332889981838499 0 0);
--color-blue: oklch(0.6417644680341144 0.19507618434109797 254.9761894488772);
--gradient: oklch(0.6417644680341144 0.19507618434109797 254.9761894488772) 0%, oklch(0.6758651948287796 0.19530567236823002 347.4630536462303) 100%;
--shadow-simple: 0 4px 8px 0 oklch(0 0 0 / 0.14901960784313725);
}
19 changes: 19 additions & 0 deletions packages/plugin-sass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,25 @@ In some scenarios this is preferable, but in others, this may result in too many

[Read more](https://css-tricks.com/data-uris/)

### Color Format

By specifying a `colorFormat`, you can transform all your colors to [any browser-supported colorspace](https://www.w3.org/TR/css-color-4/). Any of the following colorspaces are accepted:

- [hex](https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color) (default)
- [rgb](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb)
- [hsl](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl)
- [hwb](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hwb)
- [lab](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lab)
- [lch](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lch)
- [oklab](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklab)
- [oklch](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch)
- [p3](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color)
- [srgb-linear](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)
- [xyz-d50](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)
- [xyz-d65](https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method)

If you are unfamiliar with these colorspaces, the default `hex` value is best for most users (though [you should use OKLCH to define your colors](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl)).

### Transform

Inside plugin options, you can specify an optional `transform()` function:
Expand Down
Loading

0 comments on commit c97edd6

Please sign in to comment.