Skip to content

feat: New checkAndEvaluateMath function that prevents unit mixing#357

Closed
floscr wants to merge 10 commits intomainfrom
errors-sd-transform
Closed

feat: New checkAndEvaluateMath function that prevents unit mixing#357
floscr wants to merge 10 commits intomainfrom
errors-sd-transform

Conversation

@floscr
Copy link

@floscr floscr commented May 27, 2025

Draft PR on sd-transform checkAndEvaluateMath to get feedback from token-studio team and outside contributers if wanted.

Changes

  • Not allowing math on relative units like 10em + 10px this doesn't make sense IMO to resolve as em is relative to the dom context that we don't know about.
    This is something that wont be allowed from penpot, if we really want to keep the previous way it could be done with a flag.
  • Throwing explicit errors to work nicely with the new SD errors API
  • Removed the post css transform, it wasnt required with the new rules.
  • Unitless numbers don't get converted to px anymore, this shouldn't be inferred by the transform but defined by the user

Issues

Composite token values error handling

Composite token values are an issue for this for this with the new error handling:

          value: {
            width: '6px / 4',
            style: 'solid',
            color: '#000',
          },

It will throw on style and color, which shouldn't be handled in the first place by the math expression.

I think it would make sense to explicitely filter out properties that can be evaluated.

Discussion Points

New major version / New transform / strict property in config?

Possibly we call this strictCheckAndEvaluateMath which is much more simplified and doesn't result in unexpected outputs as the previous version did in regards to unit mixing.

If users want to mix units as it would work in css (e.g.: calc(10em + 10px)) there should be a new transform that runs after this one to wrap into css calc expressions.

Px Conversion of unitless at the end

When no unit is given I don't think we should append px to it and keep it as an Int.
If users want to have px values they should either define it in a config or add an extra transform.

@changeset-bot
Copy link

changeset-bot bot commented May 27, 2025

⚠️ No Changeset found

Latest commit: e6d1b21

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

test: update tests to throw error for mixed units

refactor: Add custom MixedUnitsExpressionError for better error handling

refactor: simplify unit parsing logic and add debug logging

fix: handle multiplication with different units in math expressions

Detect units

Unit parsing

Unit tests

Last tests
@floscr floscr force-pushed the errors-sd-transform branch from a0e9378 to fd1a798 Compare May 27, 2025 07:35
Comment on lines -73 to -78
expect(
checkAndEvaluateMath({
value: 'linear-gradient(90deg, #354752 0%, #0b0d0e 100%)',
type: 'dimension',
}),
).to.equal('linear-gradient(90deg, #354752 0%, #0b0d0e 100%)');
Copy link
Author

Choose a reason for hiding this comment

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

This should be filtered out before this transform, linear gradient on a dimension token doesnt make any sense...

Copy link
Contributor

Choose a reason for hiding this comment

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

As the comment says, it's just a smoke-test that ensures we don't handle values that aren't math expressions but do happen to have space seperated substrings. This could be put into its own test.

Math transform applies to all tokens that are strings. It doesn't exclude token types that aren't dimensions. We just need to check that a string value is actually a valid math expression and ignore it if it's not, that's what this expect verifies.

--spacingSm: 8px;
--spacingXl: 64px;
--spacingMultiValue: 8px 64px; /* You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
--spacingMultiValue: 8px 64px; /** You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens */
Copy link
Author

@floscr floscr May 27, 2025

Choose a reason for hiding this comment

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

Bug in new SD version? It's either missing an extra * at the end or the extra * should be removed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Feature! This makes generated comments, whether multi or single line, compatible with JSDocs, just a small enhancement. This way folks can put for example @deprecated into the comment.

* Numbers without units will be represented in the units set with an empty string "".
*/
export function parseUnits(expr: string): { units: Set<string>; unitlessExpr: string } {
const unitRegex = /(\d+\.?\d*)(?<unit>([a-zA-Z]|%)+)?/g;
Copy link
Author

@floscr floscr May 27, 2025

Choose a reason for hiding this comment

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

Additionally I would throw on any unsupported units which should be definable by the user in the platform config.
For penpot we would allow unitless|px|rem for example

@DarioSoller
Copy link
Contributor

@floscr do you mind to sum up a brief PR description? An example on what this PR is changing, would be nice. THX!
Also I would be happy to test this against my token set.

@floscr
Copy link
Author

floscr commented May 27, 2025

@floscr do you mind to sum up a brief PR description? An example on what this PR is changing, would be nice. THX! Also I would be happy to test this against my token set.

Sure! I've moved internal discussion description and points here, but please concider that this is still a draft PR.

@floscr floscr force-pushed the errors-sd-transform branch from 08daf20 to fef81ee Compare May 27, 2025 09:29
@floscr floscr force-pushed the errors-sd-transform branch from fef81ee to 5ba7cdf Compare May 27, 2025 09:29
@floscr floscr force-pushed the errors-sd-transform branch from 5ba7cdf to f2eeb9a Compare May 27, 2025 09:30
mathChars.includes(right) && mathChars.includes(left), // left/right are both math chars
left === '' && mathChars.includes(right), // tail of expr, right is math char
right === '' && mathChars.includes(left), // head of expr, left is math char
mathChars.has(tok), // current token is a math char
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice optimization 👍🏻

end: '--sdDeepRef: italic 800 26px/1.25 Arial;',
});
const expectedOutput = `--sdCompositionSize: 24px;
// TODO: Why did this change?
Copy link
Contributor

Choose a reason for hiding this comment

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

SD v5 refactored quite a few things about composite token expansion, optimizing it a lot. I suspect this is why the order of some tokens has changed because in the old SD we expanded tokens and then flattened, in the new SD we first flatten and then expand.

expect(checkAndEvaluateMath({ value: '4em + 7em', type: 'dimension' })).to.equal('11em');
expect(checkAndEvaluateMath({ value: '4 + 7rem', type: 'dimension' })).to.equal('4 + 7rem');
expect(checkAndEvaluateMath({ value: '4em + 7rem', type: 'dimension' })).to.equal('4em + 7rem');
expect(() => checkAndEvaluateMath({ value: '4 + 7rem', type: 'dimension' })).to.throw(
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems fine by me but we do need to ensure we have an integration test somewhere that verifies that the token value remains unchanged if the math throws an error. So the behavior of the token value is actually the same (which is important for backwards compatibility), except this function throws now.

Comment on lines -40 to +56
expect(checkAndEvaluateMath({ value: '4px * 7em', type: 'dimension' })).to.equal('28em');
// 50em would be incorrect when dividing, as em grows, result should shrink, but doesn't
expect(checkAndEvaluateMath({ value: '1000 / 20em', type: 'dimension' })).to.equal(
'1000 / 20em',
expect(() => checkAndEvaluateMath({ value: '4px * 7em', type: 'dimension' })).to.throw(
MixedUnitsExpressionError,
Copy link
Contributor

@jorenbroekema jorenbroekema May 29, 2025

Choose a reason for hiding this comment

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

Technically you can mix units with pixels, assuming the unit that you mix with resolves to pixels (e.g. is a multiple of). Same for division.

What I think we should do, also for backwards compatibility, is make sure that we add a platform option "ts/resolveMath:strict" which is set to false by default and allows value mixing where possible. But if strict is set to true, it will throw errors even for unit mixing that technically "can work". We will document this option and recommend it be set to true for everyone, and probably even warn users that in the future we'll probably break this and set it to true by default, because unit-mixing in design token math expressions is just not a good practice. Or we can go a step further and deprecate the option altogether to be forced to strict in the future.

The reason I wanna call it "ts/resolveMath:strict" and essentially namespace the option by the transform name, is because at this time SD only allows passing options to transforms via the platform options. And due to that scope of platform options, I think it's smart that options meant to be used by transforms are namespaced by the transform name for now.

In SD v6 we'll probably redesign that a bit and make it easier to pass options to transforms, rather than via upper scope in a hacky way.

Copy link
Author

Choose a reason for hiding this comment

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

The unit + unitless mixing is one of the issues that can't quite be solved easily with the current eval-expr, but would be solved by using the custom parser.

Copy link
Contributor

@jorenbroekema jorenbroekema left a comment

Choose a reason for hiding this comment

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

Looks good for the most part at least on the tests etc.

I think we need to reach a consensus on how we will provide backwards compatibility for right now:

  • 2 separate transforms
  • 1 transform but with an option (strict) to configure (I prefer this)

and make a plan for how we will do it in the future, e.g.:

  • force it to strict and don't allow to configure it (I prefer this, but we have to document clearly and give users quite a long time to move and make their tokens math expr consistent with units)
  • set it to strict default but allow not strict as opt-in, etc.

I might have missed it in the tests but I think that combining unitless with unit should always be allowed, given that the unit is consistent everywhere. Then we can just assume that 5rem * 5 = 25rem, and 5px * 5 = 5px etc.

I still have to more thoroughly review the actual implementation changes but that's something more easily done in a call together after this is out of draft state. So far I like what I see though.

@floscr
Copy link
Author

floscr commented Jun 2, 2025

Thanks for the feedback 🙌
I've moved the transform behind a config for now, but will also work on adding the changes from our slack channel (which I will then outline in the issue description once completed)
https://github.com/tokens-studio/sd-transforms/pull/359/files

@floscr floscr closed this Jun 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants