Skip to content

Fix/348 modify mix value calculations #351

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

Closed
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,082 changes: 2,278 additions & 2,804 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"prepare": "husky install",
"release": "npm run build && changeset publish",
"test": "npm run test:unit && npm run test:integration",
"test:integration": "ts-mocha -n loader=ts-node/esm -p tsconfig.json test/**/*.test.ts",
"test:integration": "vitest run",
"test:unit": "web-test-runner --coverage",
"test:unit:coverage": "cd coverage/lcov-report && npx http-server -o -c-1",
"test:unit:watch": "web-test-runner --watch"
Expand All @@ -36,7 +36,7 @@
"@bundled-es-modules/deepmerge": "^4.3.1",
"@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6",
"@tokens-studio/types": "^0.5.1",
Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

We will have to update the types, after my PR for the @tokens-studio/types is merged.

"colorjs.io": "^0.4.3",
"colorjs.io": "^0.5.2",
"expr-eval-fork": "^2.0.2",
"is-mergeable-object": "^1.1.1"
},
Expand Down Expand Up @@ -66,9 +66,8 @@
"prettier-package-json": "^2.8.0",
"rimraf": "^6.0.1",
"tinycolor2": "^1.6.0",
"ts-mocha": "^10.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.5.2"
"typescript": "^5.8.2",
"vitest": "^3.0.9"
},
"keywords": [
"design tokens",
Expand Down
2 changes: 2 additions & 0 deletions src/TransformOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export type ColorModifierFormat = 'hex' | 'hsl' | 'lch' | 'p3' | 'srgb';

export interface ColorModifierOptions {
format: ColorModifierFormat;
mathFractionDigits?: number;
precision?: number;
}

export interface TransformOptions {
Expand Down
11 changes: 5 additions & 6 deletions src/checkAndEvaluateMath.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { DesignToken } from 'style-dictionary/types';
import { Parser } from 'expr-eval-fork';
import { parse, reduceExpression } from '@bundled-es-modules/postcss-calc-ast-parser';
import { defaultFractionDigits } from './utils/constants.js';

const defaultFractionDigits = 4;
const mathChars = ['+', '-', '*', '/'];

const parser = new Parser();

function checkIfInsideGroup(expr: string, fullExpr: string): boolean {
Expand Down Expand Up @@ -75,7 +74,7 @@ function splitMultiIntoSingleValues(expr: string): string[] {
return [expr];
}

function parseAndReduce(expr: string, fractionDigits = defaultFractionDigits): string | number {
export function parseAndReduce(expr: string, mathFractionDigits: number): string | number {
let result: string | number = expr;

// Check if expression is already a number
Expand Down Expand Up @@ -146,14 +145,14 @@ function parseAndReduce(expr: string, fractionDigits = defaultFractionDigits): s
}

// the outer Number() gets rid of insignificant trailing zeros of decimal numbers
const reducedToFixed = Number(Number.parseFloat(`${result}`).toFixed(fractionDigits));
const reducedToFixed = Number(Number.parseFloat(`${result}`).toFixed(mathFractionDigits));
result = resultUnit ? `${reducedToFixed}${resultUnit}` : reducedToFixed;
return result;
}

export function checkAndEvaluateMath(
token: DesignToken,
fractionDigits?: number,
mathFractionDigits = defaultFractionDigits,
): DesignToken['value'] {
const expr = token.$value ?? token.value;
const type = token.$type ?? token.type;
Expand All @@ -167,7 +166,7 @@ export function checkAndEvaluateMath(
return expr;
}
const exprs = splitMultiIntoSingleValues(expr);
const reducedExprs = exprs.map(_expr => parseAndReduce(_expr, fractionDigits));
const reducedExprs = exprs.map(_expr => parseAndReduce(_expr, mathFractionDigits));
if (reducedExprs.length === 1) {
return reducedExprs[0];
}
Expand Down
2 changes: 1 addition & 1 deletion src/color-modifiers/darken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function darken(color: Color, colorSpace: ColorSpaceTypes, amount: number
}

default: {
return color.darken(amount);
return color.darken(amount) as Color;
}
}
}
2 changes: 1 addition & 1 deletion src/color-modifiers/lighten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function lighten(color: Color, colorSpace: ColorSpaceTypes, amount: numbe
return color;
}
default: {
return color.lighten(amount);
return color.lighten(amount) as Color;
}
}
}
3 changes: 2 additions & 1 deletion src/color-modifiers/mix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export function mix(
colorSpace: ColorSpaceTypes,
amount: number,
mixColor: Color,
precision: number,
): Color {
const mixValue = Math.max(0, Math.min(1, Number(amount)));

return new Color(color.mix(mixColor, mixValue, { space: colorSpace }).toString());
return new Color(color.mix(mixColor, mixValue, { space: colorSpace }).toString({ precision }));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

taking the actual configured color precision, instead of always falling back to the colorJS.io default precision.

Copy link
Member

Choose a reason for hiding this comment

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

I think we should use a single property here, let's reuse mathFractionDigits as well here. They are kinda the same thing, amount of digits after the decimal point for numbers. Specifying a different mathFractionDigits for potential math expressions in color definitions and then also a different precision for the final output doesn't really make sense to me to be honest.

Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

Just be aware, that you then have a coupling between spacing calculations and color calculations, especially in the color modifiers. I am not 100% sure, if this coupling makes sense? More details in this discussion. But I agree, that it would make things much simpler in the code.

}
28 changes: 22 additions & 6 deletions src/color-modifiers/modifyColor.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Color from 'colorjs.io';

Check failure on line 1 in src/color-modifiers/modifyColor.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'mathFractionDigits' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/modifyColor.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'mathFractionDigits' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/modifyColor.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'precision' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/modifyColor.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'precision' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/modifyColor.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'precision' does not exist on type 'ColorModifier'.
import { transparentize } from './transparentize.js';
import { mix } from './mix.js';
import { darken } from './darken.js';
import { lighten } from './lighten.js';
import { ColorModifier } from '@tokens-studio/types';
import { parseAndReduce } from '../checkAndEvaluateMath.js';
import { defaultColorPrecision, defaultFractionDigits } from '../utils/constants.js';

// Users using UIColor swift format are blocked from using such transform in
// combination with this color modify transform when using references.
Expand Down Expand Up @@ -36,27 +38,41 @@
}

baseColor = parseUIColor(baseColor);

const color = new Color(baseColor);
let returnedColor = color;

let resolvedMathFractionDigits: number = defaultFractionDigits;
if (modifier?.mathFractionDigits) {
resolvedMathFractionDigits = modifier.mathFractionDigits;
}
Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

Actually modifier.mathFractionDigits will never be undefined here, but I had to initialze resolvedMathFractionDigits that way, because otherwise I would have needed to change the ColorModifyOptions type in such a way, that mathFractionDigits is not optional. More elegant ways of initialization stumbled over 100% code coverage in the tests. Because of backwards compatibility, I think it's fine like this then?

Comment on lines +44 to +47
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let resolvedMathFractionDigits: number = defaultFractionDigits;
if (modifier?.mathFractionDigits) {
resolvedMathFractionDigits = modifier.mathFractionDigits;
}
const resolvedMathFractionDigits = modifier.mathFractionDigits ?? defaultFractionDigits;

If coverage complains then I think we need to add a test to cover the branch that is not covered.
If typescript complains because modifier can be undefined then we need to fix that because it can't be undefined. I'd be really surprised if it thinks it can be, it's not an optional parameter. I can look into it more deeply if it's an issue.

const modifyValueResolvedCalc = Number(
parseAndReduce(modifier.value, resolvedMathFractionDigits),
);

let resolvedColorPrecision: number = defaultColorPrecision;
if (modifier?.precision) {
resolvedColorPrecision = modifier.precision;
}
Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

Same here: Actually modifier. precision will never be undefined here, but I had to initialze resolvedColorPrecision that way, because otherwise I would have needed to change the ColorModifyOptions type in such a way, that precision is not optional. More elegant ways of initialization stumbled over 100% code coverage in the tests. Because of backwards compatibility, I think it's fine like this then?

Copy link
Member

Choose a reason for hiding this comment

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

see other comment, I can look into it.


try {
switch (modifier.type) {
case 'lighten':
returnedColor = lighten(color, modifier.space, Number(modifier.value));
returnedColor = lighten(color, modifier.space, modifyValueResolvedCalc);
break;
case 'darken':
returnedColor = darken(color, modifier.space, Number(modifier.value));
returnedColor = darken(color, modifier.space, modifyValueResolvedCalc);
break;
case 'mix':
returnedColor = mix(
color,
modifier.space,
Number(modifier.value),
modifyValueResolvedCalc,
new Color(modifier.color),
resolvedColorPrecision,
);
break;
case 'alpha': {
returnedColor = transparentize(color, Number(modifier.value));
returnedColor = transparentize(color, modifyValueResolvedCalc);
break;
}
default:
Expand All @@ -77,7 +93,7 @@

return returnedColor.toString({
inGamut: true,
precision: 3,
precision: modifier.precision,
format: modifier.format,
});
} catch (e) {
Expand Down
8 changes: 8 additions & 0 deletions src/color-modifiers/transformColorModifiers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { DesignToken } from 'style-dictionary/types';

Check failure on line 1 in src/color-modifiers/transformColorModifiers.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'mathFractionDigits' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/transformColorModifiers.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'mathFractionDigits' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/transformColorModifiers.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'precision' does not exist on type 'ColorModifier'.

Check failure on line 1 in src/color-modifiers/transformColorModifiers.ts

View workflow job for this annotation

GitHub Actions / Verify changes (20.x)

Property 'precision' does not exist on type 'ColorModifier'.
import { usesReferences } from 'style-dictionary/utils';
import { modifyColor } from './modifyColor.js';
import { ColorModifier } from '@tokens-studio/types';
import { ColorModifierOptions } from '../TransformOptions.js';
import { defaultFractionDigits, defaultColorPrecision } from '../utils/constants.js';

/**
* Helper: Transforms color tokens with tokens studio color modifiers
*/
Expand All @@ -22,5 +24,11 @@
if (options?.format) {
modifier.format = options.format;
}
if (!modifier.mathFractionDigits) {
modifier.mathFractionDigits = options?.mathFractionDigits ?? defaultFractionDigits;
}
if (!modifier.precision) {
modifier.precision = options?.precision ?? defaultColorPrecision;
}
Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

If mathFractionDigits or precision are not set on the token level modify, then either use the respective configuration coming from the platform config or the transform options, or fall back to the default values for mathFractionDigits and precision.

Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we should just pass the "options" to modifyColor function, and handle this logic in there. Since there it already has logic that deals with the fallback values. So then it would just be:
const resolvedMathFractionDigits = modifier.mathFractionDigits ?? options?.mathFractionDigits ?? defaultFractionDigits;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will pick this topic up again, as soon as we know more, in which direction ColorJS.io is heading now.

Copy link
Member

Choose a reason for hiding this comment

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

It would seem to me that we should max out precision for now and then constrain the amount of digits after the decimal period based on mathFractionDigits option that's also used for the resolve math transform. This essentially adds the precision, in the intuitive manner. It feels to me that ColorJS.io will not end up agreeing with my stance on precision, and I'm not that interested in creating a PR for adding a 2nd option if it means that I'm just going to end up turning off precision 😅 I can already do that now.

Copy link
Member

Choose a reason for hiding this comment

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

Ah sigh, I just realized during the color modify, it's kinda hard to adjust the amount of decimals, because values are usually strings of some kind of color format... So I guess we do need to either park it until the colorjs.io work is done or we need to just pick a sensible precision for now like 5, and call it a bugfix..

return modifyColor(token.$value ?? token.value, modifier);
}
55 changes: 51 additions & 4 deletions src/register.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PreprocessedTokens } from 'style-dictionary/types';
import StyleDictionary from 'style-dictionary';
import StyleDictionary, { PlatformConfig } from 'style-dictionary';
import { transformDimension } from './transformDimension.js';
import { transformHEXRGBaForCSS } from './css/transformHEXRGBa.js';
import { transformFontWeight } from './transformFontWeight.js';
Expand All @@ -9,10 +9,11 @@ import { transformTypographyForCompose } from './compose/transformTypography.js'
import { checkAndEvaluateMath } from './checkAndEvaluateMath.js';
import { mapDescriptionToComment } from './mapDescriptionToComment.js';
import { transformColorModifiers } from './color-modifiers/transformColorModifiers.js';
import { TransformOptions } from './TransformOptions.js';
import { ColorModifierOptions, TransformOptions } from './TransformOptions.js';
import { transformOpacity } from './transformOpacity.js';
import { parseTokens } from './preprocessors/parse-tokens.js';
import { transformShadow } from './css/transformShadow.js';
import { defaultFractionDigits } from './utils/constants.js';

export const getTransforms = (transformOpts?: TransformOptions) => {
const agnosticTransforms = [
Expand Down Expand Up @@ -73,7 +74,8 @@ export async function register(sd: typeof StyleDictionary, transformOpts?: Trans
type: 'value',
transitive: true,
filter: token => ['string', 'object'].includes(typeof (token.$value ?? token.value)),
transform: (token, platformCfg) => checkAndEvaluateMath(token, platformCfg.mathFractionDigits),
transform: (token, platformCfg) =>
checkAndEvaluateMath(token, platformCfg.mathFractionDigits ?? defaultFractionDigits),
});

sd.registerTransform({
Expand Down Expand Up @@ -171,7 +173,52 @@ export async function register(sd: typeof StyleDictionary, transformOpts?: Trans
(token.$type ?? token.type) === 'color' &&
token.$extensions &&
token.$extensions['studio.tokens']?.modify,
transform: token => transformColorModifiers(token, transformOpts?.['ts/color/modifiers']),
transform: (token, platformCfg) => {
const hasTransformOpts = transformOpts?.['ts/color/modifiers'];
const hasPlatformPrecision = platformCfg.precision !== undefined;
const hasPlatformMathFractionDigits = platformCfg.mathFractionDigits !== undefined;

if (hasTransformOpts || hasPlatformPrecision || hasPlatformMathFractionDigits) {
const resolveColorModifyTransformOpts = (
platformCfg: PlatformConfig,
transformOpts?: TransformOptions,
): ColorModifierOptions | undefined => {
let resolvedOpts: ColorModifierOptions | undefined;

if (transformOpts?.['ts/color/modifiers']) {
if (transformOpts['ts/color/modifiers']?.precision) {
resolvedOpts = resolvedOpts ? resolvedOpts : ({} as ColorModifierOptions);
resolvedOpts.precision = transformOpts['ts/color/modifiers']?.precision;
}

if (transformOpts['ts/color/modifiers']?.mathFractionDigits) {
resolvedOpts = resolvedOpts ? resolvedOpts : ({} as ColorModifierOptions);
resolvedOpts.mathFractionDigits =
transformOpts['ts/color/modifiers']?.mathFractionDigits;
}
}

if (platformCfg.precision) {
resolvedOpts = {} as ColorModifierOptions;
resolvedOpts.precision = platformCfg.precision;
}

if (platformCfg.mathFractionDigits) {
resolvedOpts = resolvedOpts ? resolvedOpts : ({} as ColorModifierOptions);
resolvedOpts.mathFractionDigits = platformCfg.mathFractionDigits;
}

return resolvedOpts;
};
Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

Might have room for improvement, if you have an idea?

First check if mathFractionDigits or precision are set in the platform config. If they are also set in the transform options, then the platform configs get overwritten. So it also supports the case that either one of them is set on the platform level and on the transform option level.

As we now have precision also in the platform level, I think I have to make a PR for updating the platform types in Style-Dictionary? If so it might actually be better to rename precision to colorPrecision, so that it is clear that this only alters the precision with which colors are represented in the output files?

Copy link
Member

@jorenbroekema jorenbroekema Mar 31, 2025

Choose a reason for hiding this comment

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

Yeah I'll take a look at making this a bit more elegant.

I think to summarize we have this order of precedence, from highest prio to least:

  • Token definition -> modify -> precision
  • Platform config -> precision
  • SD-transforms register(SD, options) -> options -> transformName -> precision
  • default values if none of the above is defined

In v6 I want to be able to pass transform options a bit differently, similar to how you do with formats. Then in the above list we can remove 2 and 3 and instead we use the new method of passing transform options.

Copy link
Contributor Author

@DarioSoller DarioSoller Mar 31, 2025

Choose a reason for hiding this comment

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

Yeah right. As I am reading your comment, I might have assumed another order of priority like:

  1. Token definition -> modify -> precision
  2. SD-transforms register(SD, options) -> options -> transformName -> precision
  3. Platform config -> precision
  4. default values if none of the above is defined

But your's makes definitely more sense, as I am thinking of it. Little adjustment needed then in the code: register.ts > resolveColorModifyTransformOpts()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I corrected the order accordingly.


const resolvedColorModifyTransformOpts: ColorModifierOptions | undefined =
resolveColorModifyTransformOpts(platformCfg, transformOpts);

return transformColorModifiers(token, resolvedColorModifyTransformOpts);
}

return transformColorModifiers(token);
},
});

const includeBuiltinGroup = transformOpts?.withSDBuiltins ?? true;
Expand Down
5 changes: 5 additions & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const defaultFractionDigits = 4;

// 3 for backwards compatibility, but should better be 5, referring to the default precision (5) of the colorJS.io library
// see: https://colorjs.io/docs/output.html#getting-a-string-representation-of-a-color
export const defaultColorPrecision = 3;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we now decide to just stick with the defaultColorPrecision of 5, I would recommend, that we still explicitly set this precision, instead of relying on the ColorJS default. As this precision setting has quite some unintuitive impact on (mixed) colors, I would really define it explicitly. Let me know, if the place where I have put constants.ts makes sense for you?

Loading
Loading