Skip to content

Commit

Permalink
Improve gamut clamping (#228)
Browse files Browse the repository at this point in the history
* Improve gamut clamping

* Add docs

* Improve Tokens Studio inline aliasing
  • Loading branch information
drwpow authored Mar 25, 2024
1 parent 125e1a4 commit 1e12d04
Show file tree
Hide file tree
Showing 14 changed files with 484 additions and 84 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-cooks-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/core": patch
---

Improve Tokens Studio inline aliasing
5 changes: 5 additions & 0 deletions .changeset/fresh-ears-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/core": minor
---

Add gamut clipping for color tokens
5 changes: 5 additions & 0 deletions .changeset/young-countries-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/core": patch
---

Make parse options optional for easier use
8 changes: 5 additions & 3 deletions docs/advanced/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,14 @@ Some token types allow for extra configuration.
export default {
color: {
convertToHex: false, // Convert all colors to sRGB hexadecimal (default: false). By default, colors are kept in their formats
gamut: undefined, // 'srgb' | 'p3'
},
};
```

:::

| Name | Type | Description |
| :------------------- | :-------: | :--------------------------------------------------------------------------------------------------------------- |
| `color.convertToHex` | `boolean` | Convert this color to sRGB hexadecimal. By default, colors are kept in the original formats they’re authored in. |
| Name | Type | Description |
| :------------------- | :--------------: | :----------------------------------------------------------------------------------------- |
| `color.convertToHex` | `boolean` | (optional) Convert colors to 8-bit sRGB hexadecimal. |
| `color.gamut` | `'srgb' \| 'p3'` | (optional) Clamp colors to the `'srgb'` or `'p3'` gamut? (default: leave colors untouched) |
4 changes: 2 additions & 2 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"vue": "^3.4.21"
},
"devDependencies": {
"vite": "^5.1.6",
"vitepress": "1.0.0-rc.45"
"vite": "^5.2.6",
"vitepress": "1.0.1"
}
}
4 changes: 4 additions & 0 deletions docs/tokens/color.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Color is a frequently-used base token that can be aliased within the following c
- [Shadow](/tokens/shadow)
- [Gradient](/tokens/gradient)

## Global options

See [color-specific configuration options](/advanced/config#color)

## Tips & recommendations

- [Culori](https://culorijs.org/) is the preferred library for working with color. It’s great both as an accurate, complete color science library that can parse & generate any format. But is also easy-to-use for simple color operations and is fast and [lightweight](https://culorijs.org/guides/tree-shaking/) (even on the client).
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/parse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export type LintRuleSeverity = 'error' | 'warn' | 'off' | number;

export interface ParseOptions {
/** Configure transformations for color tokens */
color: ParseColorOptions;
color?: ParseColorOptions;
figma?: FigmaParseOptions;
/** Configure plugin lint rules (if any) */
lint: {
rules: Record<string, LintRule>;
lint?: {
rules?: Record<string, LintRule>;
};
}

Expand Down
78 changes: 70 additions & 8 deletions packages/core/src/parse/tokens-studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* This works by first converting the Tokens Studio format
* into an equivalent DTCG result, then parsing that result
*/
import { parseAlias } from '@cobalt-ui/utils';
import { isAlias, parseAlias } from '@cobalt-ui/utils';
import { parse as culoriParse, rgb } from 'culori';
import type { GradientStop, Group, Token } from '../token.js';

// I’m not sure this is comprehensive at all but better than nothing
Expand Down Expand Up @@ -284,9 +285,12 @@ export function convertTokensStudioFormat(rawTokens: Record<string, unknown>): {
`Token "${tokenID}" is a multi value borderRadius token. Expanding into ${tokenID}TopLeft, ${tokenID}TopRight, ${tokenID}BottomRight, and ${tokenID}BottomLeft.`,
);
let order = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TL, BR
if (values.length === 3)
{order = [values[0], values[1], values[2], values[1]] as [string, string, string, string];} // TL, TR/BL, BR
else if (values.length === 4) {order = [values[0], values[1], values[2], values[3]] as [string, string, string, string];} // TL, TR, BR, BL
if (values.length === 3) {
order = [values[0], values[1], values[2], values[1]] as [string, string, string, string];
} // TL, TR/BL, BR
else if (values.length === 4) {
order = [values[0], values[1], values[2], values[3]] as [string, string, string, string];
} // TL, TR, BR, BL
addToken({ $type: 'dimension', $value: order[0], $description: v.description }, [...path, `${k}TopLeft`]);
addToken({ $type: 'dimension', $value: order[1], $description: v.description }, [...path, `${k}TopRight`]);
addToken({ $type: 'dimension', $value: order[2], $description: v.description }, [...path, `${k}BottomRight`]);
Expand Down Expand Up @@ -365,10 +369,65 @@ export function convertTokensStudioFormat(rawTokens: Record<string, unknown>): {
tokenPath,
);
} else {
let value: string | undefined = v.value;
// resolve inline aliases (e.g. `rgba({color.black}, 0.5)`)
if (value.includes('{') && !v.value.startsWith('{')) {
value = resolveAlias(value, path);

if (!value) {
errors.push(`Could not resolve "${v.value}"`);
continue;
}

// note: we did some work earlier to help resolve the aliases, but
// we need to REPLACE them in this scenario so we must do a 2nd pass
const matches = value.match(ALIAS_RE);
for (const match of matches ?? []) {
let currentAlias = parseAlias(match).id;
let resolvedValue: string | undefined;
const aliasHistory = new Set<string>([currentAlias]);
while (!resolvedValue) {
const aliasNode: any = get(rawTokens, currentAlias.split('.'));
// does this resolve to a $value?
if (aliasNode && aliasNode.value) {
// is this another alias?
if (isAlias(aliasNode.value)) {
currentAlias = parseAlias(aliasNode.value).id;
if (aliasHistory.has(currentAlias)) {
errors.push(`Couldn’t resolve circular alias "${v.value}"`);
break;
}
aliasHistory.add(currentAlias);
continue;
}
resolvedValue = aliasNode.value;
}
break;
}

if (resolvedValue) {
value = value.replace(match, resolvedValue);
}
}

if (!culoriParse(value)) {
// fix `rgba(#000000, 0.3)` scenario specifically (common Tokens Studio version)
// but throw err otherwise
if (value.startsWith('rgb') && value.includes('#')) {
const hexValue = value.match(/#[abcdef0-9]+/i);
if (hexValue && hexValue[0]) {
const rgbVal = rgb(hexValue[0]);
if (rgbVal) {
value = value.replace(hexValue[0], `${rgbVal.r * 100}%, ${rgbVal.g * 100}%, ${rgbVal.b * 100}%`);
}
}
}
}
}
addToken(
{
$type: 'color',
$value: v.value,
$value: value,
$description: v.description,
},
tokenPath,
Expand Down Expand Up @@ -441,9 +500,12 @@ export function convertTokensStudioFormat(rawTokens: Record<string, unknown>): {
} else if (values.length === 2 || values.length === 3 || values.length === 4) {
warnings.push(`Token "${tokenID}" is a multi value spacing token. Expanding into ${tokenID}Top, ${tokenID}Right, ${tokenID}Bottom, and ${tokenID}Left.`);
let order: [string, string, string, string] = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TB, RL
if (values.length === 3)
{order = [values[0], values[1], values[2], values[1]] as [string, string, string, string];} // T, RL, B
else if (values.length === 4) {order = [values[0], values[1], values[2], values[3]] as [string, string, string, string];} // T, R, B, L
if (values.length === 3) {
order = [values[0], values[1], values[2], values[1]] as [string, string, string, string];
} // T, RL, B
else if (values.length === 4) {
order = [values[0], values[1], values[2], values[3]] as [string, string, string, string];
} // T, R, B, L
addToken({ $type: 'dimension', $value: order[0], $description: v.description }, [...path, `${k}Top`]);
addToken({ $type: 'dimension', $value: order[1], $description: v.description }, [...path, `${k}Right`]);
addToken({ $type: 'dimension', $value: order[2], $description: v.description }, [...path, `${k}Bottom`]);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/parse/tokens/border.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { normalizeDimensionValue } from './dimension.js';
import { normalizeStrokeStyleValue } from './stroke-style.js';

export interface ParseBorderOptions {
color: ParseColorOptions;
color?: ParseColorOptions;
}

/**
Expand Down
43 changes: 30 additions & 13 deletions packages/core/src/parse/tokens/color.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { formatHex, formatHex8, parse } from 'culori';
import { type Color, clampChroma, formatHex, formatHex8, parse, formatCss } from 'culori';
import type { ParsedColorToken } from '../../token.js';

export interface ParseColorOptions {
/** Convert color to sRGB hex? (default: true) */
/** Convert color to 8-bit sRGB hexadecimal? (default: false) */
convertToHex?: boolean;
/** Confine colors to gamut? sRGB is smaller but widely-supported; P3 supports more colors but not all users (default: `undefined`) */
gamut?: 'srgb' | 'p3' | undefined;
}

/**
Expand All @@ -15,19 +17,34 @@ export interface ParseColorOptions {
* "$value": "#ff00ff"
* }
*/
export function normalizeColorValue(value: unknown, options: ParseColorOptions): ParsedColorToken['$value'] {
if (!value) {
export function normalizeColorValue(rawValue: unknown, options?: ParseColorOptions): ParsedColorToken['$value'] {
if (!rawValue) {
throw new Error('missing value');
}
if (typeof value === 'string') {
if (options.convertToHex === true) {
const parsed = parse(value);
if (!parsed) {
throw new Error(`invalid color "${value}"`);
}
return typeof parsed.alpha === 'number' && parsed.alpha < 1 ? formatHex8(parsed) : formatHex(parsed);
if (typeof rawValue === 'string') {
const parsed = parse(rawValue);
if (!parsed) {
throw new Error(`invalid color "${rawValue}"`);
}
return value;

let value = parsed as Color;
let valueEdited = false; // keep track of this to reduce rounding errors

// clamp to sRGB if we’re converting to hex, too!
if (options?.gamut === 'srgb' || options?.convertToHex === true) {
value = clampChroma(parsed, parsed.mode, 'rgb');
valueEdited = true;
} else if (options?.gamut === 'p3') {
value = clampChroma(parsed, parsed.mode, 'p3');
valueEdited = true;
}

// TODO: in 2.x, only convert to hex if no color loss (e.g. don’t downgrade a 12-bit color `rgb()` to 8-bit hex)
if (options?.convertToHex === true) {
return typeof value.alpha === 'number' && value.alpha < 1 ? formatHex8(value) : formatHex(value);
}

return valueEdited ? formatCss(value) : rawValue; // return the original value if we didn’t modify it; we may introduce nondeterministic rounding errors (the classic JS 0.3333… nonsense, etc.)
}
throw new Error(`expected string, received ${typeof value}`);
throw new Error(`expected string, received ${typeof rawValue}`);
}
2 changes: 1 addition & 1 deletion packages/core/src/parse/tokens/gradient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ParseColorOptions } from './color.js';
import { normalizeColorValue } from './color.js';

export interface ParseGradientOptions {
color: ParseColorOptions;
color?: ParseColorOptions;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/parse/tokens/shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { normalizeColorValue } from './color.js';
import { normalizeDimensionValue } from './dimension.js';

export interface ParseShadowOptions {
color: ParseColorOptions;
color?: ParseColorOptions;
}

/**
Expand Down
89 changes: 69 additions & 20 deletions packages/core/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
import { describe, expect, test } from 'vitest';
import { parse } from '../src/index.js';
import { clampChroma, formatCss } from 'culori';

describe('parse', () => {
test('sorts tokens', () => {
const {
result: { tokens },
} = parse(
{
color: {
$type: 'color',
blue: {
'70': { $value: '#4887c9' },
'10': { $value: '#062053' },
'30': { $value: '#192f7d' },
'80': { $value: '#5ca9d7' },
'40': { $value: '#223793' },
'50': { $description: 'Medium blue', $value: '#2b3faa' },
'100': { $value: '#89eff1' },
'60': { $value: '#3764ba' },
'90': { $value: '#72cce5' },
'20': { $value: '#0f2868' },
'00': { $value: '{color.black}' },
},
black: { $value: '#000000' },
} = parse({
color: {
$type: 'color',
blue: {
'70': { $value: '#4887c9' },
'10': { $value: '#062053' },
'30': { $value: '#192f7d' },
'80': { $value: '#5ca9d7' },
'40': { $value: '#223793' },
'50': { $description: 'Medium blue', $value: '#2b3faa' },
'100': { $value: '#89eff1' },
'60': { $value: '#3764ba' },
'90': { $value: '#72cce5' },
'20': { $value: '#0f2868' },
'00': { $value: '{color.black}' },
},
black: { $value: '#000000' },
},
{ color: {} },
);
});
expect(tokens.map((t) => t.id)).toEqual([
'color.black',
'color.blue.00',
Expand All @@ -42,4 +40,55 @@ describe('parse', () => {
'color.blue.100',
]);
});

describe('color options', () => {
const colorTealID = 'color.teal';
const colorTealValue = 'oklch(69.41% 0.185 179)'; // this is intentionally outside both sRGB and P3 gamuts

test('convertToHex', () => {
// convertToHex: true
const { result: result1 } = parse(
{ color: { teal: { $type: 'color', $value: colorTealValue } } },
{
color: { convertToHex: true },
},
);
expect(result1.tokens.find((t) => t.id === colorTealID)?.$value).toBe('#00b69e');

// convertToHex: false
const { result: result2 } = parse(
{ color: { teal: { $type: 'color', $value: colorTealValue } } },
{
color: { convertToHex: false },
},
);
expect(result2.tokens.find((t) => t.id === colorTealID)?.$value).toBe(colorTealValue);
});

test('gamut', () => {
const { result: srgbResult } = parse({ color: { teal: { $type: 'color', $value: colorTealValue } } }, { color: { convertToHex: false, gamut: 'srgb' } });
const srgbClamped = formatCss(clampChroma(colorTealValue, 'oklch', 'rgb'));
expect(srgbResult.tokens.find((t) => t.id === colorTealID)?.$value, 'sRGB').toBe(srgbClamped);

const { result: p3Result } = parse({ color: { teal: { $type: 'color', $value: colorTealValue } } }, { color: { convertToHex: false, gamut: 'p3' } });
const p3Clamped = formatCss(clampChroma(colorTealValue, 'oklch', 'p3'));
expect(p3Result.tokens.find((t) => t.id === colorTealID)?.$value, 'P3').toBe(p3Clamped);

const { result: untouchedResult } = parse({ color: { teal: { $type: 'color', $value: colorTealValue } } }, { color: { convertToHex: false, gamut: undefined } });
expect(untouchedResult.tokens.find((t) => t.id === colorTealID)?.$value, 'untouched').toBe(colorTealValue);

// bonus: ignore invalid values (don’t bother warning)
const { result: badResult } = parse(
{ color: { teal: { $type: 'color', $value: colorTealValue } } },
{
color: {
convertToHex: false,
// @ts-expect-error we’re doing this on purpose
gamut: 'goofballs',
},
},
);
expect(badResult.tokens.find((t) => t.id === colorTealID)?.$value, 'bad').toBe(colorTealValue);
});
});
});
Loading

0 comments on commit 1e12d04

Please sign in to comment.