Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve gamut clamping #228

Merged
merged 3 commits into from
Mar 25, 2024
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/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 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 Expand Up @@ -153,7 +153,7 @@
if (propertyKey === '$extensions') {
nextGroup.$extensions = { ...nextGroup.$extensions, ...v.$extensions };
} else {
(nextGroup as any)[propertyKey] = v[propertyKey];

Check warning on line 156 in packages/core/src/parse/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}
if (!RESERVED_KEYS.has(propertyKey)) {
if (!result.warnings) {
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 @@
`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 @@
tokenPath,
);
} else {
let value: string | undefined = v.value;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty hacky, but fixes an existing bug where rgba({colors.black}, 0.2) would get left as-is.

Does a little work to fix common scenarios, but is by no means exhaustive and there are still plenty of other broken scenarios I’m sure.

// 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 @@
} 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
Loading