Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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/enhance-rem-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/figma-plugin": patch
---

Enhanced UI display for rem values to show pixel equivalents based on current theme baseline font size. Token tooltips and inspector now display rem values as "1rem (16px)" format.
7 changes: 7 additions & 0 deletions .changeset/fix-font-size-token-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tokens-studio/figma-plugin": patch
---

Fix font size token export for different typography baselines across themes

When exporting tokens with expandTypography enabled, composite tokens (like typography tokens) now correctly use resolved values from the provided resolvedTokens array instead of the original unresolved token values. This ensures that font size tokens and other properties within typography tokens reflect the correct baseline values for each theme when tokens are resolved per theme.
5 changes: 5 additions & 0 deletions .changeset/fix-multi-theme-rem-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/figma-plugin": patch
---

Fixed multi-theme variable export to use correct base font size per theme for rem conversion. Previously, when exporting multiple themes simultaneously, all themes would use the base font size from the currently active theme due to shared TokenResolver state.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import Box from './Box';
import Tooltip from './Tooltip';
import IconBrokenLink from '@/icons/brokenlink.svg';
Expand All @@ -10,6 +11,10 @@ import { IconBorder, IconImage } from '@/icons';
import { SingleToken } from '@/types/tokens';
import { TokenTooltip } from './TokenTooltip';
import { TokenTypographyValue, TokenBoxshadowValue, TokenBorderValue } from '@/types/values';
import { TokensContext } from '@/context';
import { aliasBaseFontSizeSelector } from '@/selectors';
import { getAliasValue } from '@/utils/alias';
import { formatTokenValueForDisplay } from '@/utils/displayTokenValue';

type Props = {
name: string;
Expand All @@ -20,6 +25,18 @@ type Props = {

export default function InspectorResolvedToken({ token }: { token: Props }) {
const { t } = useTranslation(['inspect']);
const tokensContext = useContext(TokensContext);
const aliasBaseFontSize = useSelector(aliasBaseFontSizeSelector);

// Get the current resolved base font size for rem conversion
const currentBaseFontSize = React.useMemo(() => {
if (aliasBaseFontSize) {
const resolvedBaseFontSize = getAliasValue(aliasBaseFontSize, tokensContext.resolvedTokens);
return resolvedBaseFontSize ? String(resolvedBaseFontSize) : '16px';
}
return '16px';
}, [aliasBaseFontSize, tokensContext.resolvedTokens]);

// TODO: Introduce shared component for token tooltips
if (!token) {
return (
Expand Down Expand Up @@ -160,7 +177,7 @@ export default function InspectorResolvedToken({ token }: { token: Props }) {
overflow: 'hidden',
}}
>
{String(token.value)}
{formatTokenValueForDisplay(token.value, currentBaseFontSize)}
</Box>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import React from 'react';
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { styled } from '@/stitches.config';
import { TokensContext } from '@/context';
import { aliasBaseFontSizeSelector } from '@/selectors';
import { getAliasValue } from '@/utils/alias';
import { formatTokenValueForDisplay } from '@/utils/displayTokenValue';

const StyledAliasBadge = styled('div', {
padding: '$1 $2',
Expand All @@ -15,7 +20,21 @@ type Props = {
};

export default function AliasBadge({ value }: Props) {
const tokensContext = useContext(TokensContext);
const aliasBaseFontSize = useSelector(aliasBaseFontSizeSelector);

// Get the current resolved base font size for rem conversion
const currentBaseFontSize = React.useMemo(() => {
if (aliasBaseFontSize) {
const resolvedBaseFontSize = getAliasValue(aliasBaseFontSize, tokensContext.resolvedTokens);
return resolvedBaseFontSize ? String(resolvedBaseFontSize) : '16px';
}
return '16px';
}, [aliasBaseFontSize, tokensContext.resolvedTokens]);

return (
<StyledAliasBadge>{value}</StyledAliasBadge>
<StyledAliasBadge>
{formatTokenValueForDisplay(value || '', currentBaseFontSize)}
</StyledAliasBadge>
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import React from 'react';
import React, { useContext } from 'react';
import { isEqual } from '@/utils/isEqual';
import { useSelector } from 'react-redux';
import Box from '../Box';
import Stack from '../Stack';
import AliasBadge from './AliasBadge';
import { TokensContext } from '@/context';
import { aliasBaseFontSizeSelector } from '@/selectors';
import { getAliasValue } from '@/utils/alias';
import { formatTokenValueForDisplay } from '@/utils/displayTokenValue';

type Props = {
label?: string;
Expand All @@ -11,6 +16,17 @@ type Props = {
};

export default function TooltipProperty({ label, value, resolvedValue }: Props) {
const tokensContext = useContext(TokensContext);
const aliasBaseFontSize = useSelector(aliasBaseFontSizeSelector);

// Get the current resolved base font size for rem conversion
const currentBaseFontSize = React.useMemo(() => {
if (aliasBaseFontSize) {
const resolvedBaseFontSize = getAliasValue(aliasBaseFontSize, tokensContext.resolvedTokens);
return resolvedBaseFontSize ? String(resolvedBaseFontSize) : '16px';
}
return '16px';
}, [aliasBaseFontSize, tokensContext.resolvedTokens]);
return typeof value !== 'undefined' || typeof resolvedValue !== 'undefined' ? (
<Stack
direction="row"
Expand All @@ -27,7 +43,7 @@ export default function TooltipProperty({ label, value, resolvedValue }: Props)
{label}
{typeof value !== 'undefined' && (
<Box css={{ color: '$tooltipFgMuted', flexShrink: 1, wordBreak: 'break-word' }}>
{value}
{formatTokenValueForDisplay(value, currentBaseFontSize)}
</Box>
)}
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const attachLocalVariablesToTheme: AsyncMessageChannelHandlers[AsyncMessa
);
if (collection && mode) {
const collectionVariableIds: Record<string, string> = {};
const tokensToCreateVariablesFor = generateTokensToCreate({ theme, tokens });
tokensToCreateVariablesFor.forEach((token) => {
const { tokensToCreate } = generateTokensToCreate({ theme, tokens });
tokensToCreate.forEach((token) => {
const variable = figmaVariableMaps.get(token.name.split('.').join('/'));
if (variable) {
collectionVariableIds[token.name] = variable.key;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ describe('generateTokensToCreate', () => {
};

it('returns the correct tokens for enabled sets', () => {
const result = generateTokensToCreate({ theme, tokens });
const { tokensToCreate } = generateTokensToCreate({ theme, tokens });

expect(result).toEqual([
expect(tokensToCreate).toEqual([
{
name: 'primary.red',
value: '#ff0000',
Expand All @@ -56,8 +56,8 @@ describe('generateTokensToCreate', () => {
],
};

const result = generateTokensToCreate({ theme, tokens: tokensWithInvalidType });
const { tokensToCreate } = generateTokensToCreate({ theme, tokens: tokensWithInvalidType });

expect(result).toEqual([]);
expect(tokensToCreate).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,32 @@ import { TokenSetStatus } from '@/constants/TokenSetStatus';
import { tokenTypesToCreateVariable } from '@/constants/VariableTypes';
import { ThemeObject, UsedTokenSetsMap } from '@/types';
import { AnyTokenList } from '@/types/tokens';
import { defaultTokenResolver } from '@/utils/TokenResolver';
import { mergeTokenGroups } from '@/utils/tokenHelpers';
import { TokenResolver } from '@/utils/TokenResolver';
import { mergeTokenGroups, ResolveTokenValuesResult } from '@/utils/tokenHelpers';

export function generateTokensToCreate({
theme,
tokens,
filterByTokenSet,
overallConfig = {},
themeTokenResolver,
}: {
theme: ThemeObject;
tokens: Record<string, AnyTokenList>;
filterByTokenSet?: string;
overallConfig?: UsedTokenSetsMap;
}) {
themeTokenResolver?: TokenResolver;
}): { tokensToCreate: ResolveTokenValuesResult[]; resolvedTokens: ResolveTokenValuesResult[] } {
// Big O(resolveTokenValues * mergeTokenGroups)
const enabledTokenSets = Object.entries(theme.selectedTokenSets)
.filter(([name, status]) => status === TokenSetStatus.ENABLED && (!filterByTokenSet || name === filterByTokenSet))
.map(([tokenSet]) => tokenSet);
const resolved = defaultTokenResolver.setTokens(mergeTokenGroups(tokens, theme.selectedTokenSets, overallConfig));
return resolved.filter(
// Create a separate TokenResolver instance for this theme to avoid interference
// when multiple themes are processed concurrently (if not provided)
Copy link
Collaborator

Choose a reason for hiding this comment

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

i have a different PR where we remove the concurrent creation as its leading to memory issues - lets not adjust it in this PR then? #3561 just need to fix the tests there

Copy link
Collaborator

Choose a reason for hiding this comment

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

unless its absolutely required for your PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot respond to this comment

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My changes to generateTokensToCreate.ts and updateVariables.ts create separate TokenResolver instances per theme to prevent race conditions during concurrent multi-theme processing.

If PR #3561 removes concurrent processing, my changes won't conflict - they'll simply ensure each theme has its own resolver instance (even if processed sequentially). The fix is still valuable for correctness, though less critical without concurrency. The changes are minimal and won't interfere with removing Promise.all.

const resolver = themeTokenResolver || new TokenResolver([]);
const resolved = resolver.setTokens(mergeTokenGroups(tokens, theme.selectedTokenSets, overallConfig));
const tokensToCreate = resolved.filter(
(token) => ((!token.internal__Parent || enabledTokenSets.includes(token.internal__Parent)) && tokenTypesToCreateVariable.includes(token.type)), // filter out SOURCE tokens
);
return { tokensToCreate, resolvedTokens: resolved };
}
148 changes: 148 additions & 0 deletions packages/tokens-studio-for-figma/src/plugin/updateVariables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,152 @@ describe('updateVariables', () => {
});
expect(result.removedVariables).toEqual(['VariableID:1:toremove']);
});

it('should use theme-specific base font size for rem conversion', async () => {
// Clear the mock before starting
mockSetValueForMode.mockClear();

// Mock variables for font size tokens
const fontSizeVariable1rem: Variable = {
name: 'font-size/1rem',
variableCollectionId: 'VariableCollectionId:1:0',
resolvedType: 'FLOAT',
setValueForMode: mockSetValueForMode,
id: 'VariableID:1:1',
key: 'VariableID:1:1',
description: '',
valuesByMode: { '1:0': 0, '1:1': 0 }, // Initialize with 0 for both modes
remote: false,
remove: jest.fn(),
};

const fontSizeVariable2rem: Variable = {
name: 'font-size/2rem',
variableCollectionId: 'VariableCollectionId:1:0',
resolvedType: 'FLOAT',
setValueForMode: mockSetValueForMode,
id: 'VariableID:1:2',
key: 'VariableID:1:2',
description: '',
valuesByMode: { '1:0': 0, '1:1': 0 }, // Initialize with 0 for both modes
remote: false,
remove: jest.fn(),
};

const baselineVariable: Variable = {
name: 'typography/baseline',
variableCollectionId: 'VariableCollectionId:1:0',
resolvedType: 'FLOAT',
setValueForMode: mockSetValueForMode,
id: 'VariableID:1:3',
key: 'VariableID:1:3',
description: '',
valuesByMode: { '1:0': 0, '1:1': 0 }, // Initialize with 0 for both modes
remote: false,
remove: jest.fn(),
};

// Mock createVariable to return different variables based on name
figma.variables.createVariable = jest.fn().mockImplementation((name) => {
if (name === 'font-size/1rem') return fontSizeVariable1rem;
if (name === 'font-size/2rem') return fontSizeVariable2rem;
if (name === 'typography/baseline') return baselineVariable;
return newVariable;
});

// Mock getLocalVariables to return empty array (no existing variables)
figma.variables.getLocalVariables = jest.fn().mockReturnValue([]);

// Mobile theme with 16px baseline
const mobileTheme: ThemeObject = {
id: 'ThemeId:mobile',
name: 'Mobile',
group: 'Responsive',
selectedTokenSets: { mobile: TokenSetStatus.ENABLED },
};

const mobileTokens = {
mobile: [
{
name: 'typography.baseline',
value: '16px',
type: TokenTypes.FONT_SIZES,
},
{
name: 'font-size.1rem',
value: '1rem',
type: TokenTypes.FONT_SIZES,
},
{
name: 'font-size.2rem',
value: '2rem',
type: TokenTypes.FONT_SIZES,
},
],
};

const settingsWithAlias = {
...settings,
baseFontSize: '16px',
aliasBaseFontSize: '{typography.baseline}',
};

await updateVariables({
collection,
mode: '1:0',
theme: mobileTheme,
tokens: mobileTokens,
settings: settingsWithAlias,
overallConfig: { mobile: TokenSetStatus.ENABLED },
});

// Verify that 1rem was converted to 16px (1 * 16)
expect(mockSetValueForMode).toHaveBeenCalledWith('1:0', 16);
// Verify that 2rem was converted to 32px (2 * 16)
expect(mockSetValueForMode).toHaveBeenCalledWith('1:0', 32);

// Tablet theme with 15px baseline
const tabletTheme: ThemeObject = {
id: 'ThemeId:tablet',
name: 'Tablet',
group: 'Responsive',
selectedTokenSets: { tablet: TokenSetStatus.ENABLED },
};

const tabletTokens = {
tablet: [
{
name: 'typography.baseline',
value: '15px',
type: TokenTypes.FONT_SIZES,
},
{
name: 'font-size.1rem',
value: '1rem',
type: TokenTypes.FONT_SIZES,
},
{
name: 'font-size.2rem',
value: '2rem',
type: TokenTypes.FONT_SIZES,
},
],
};

mockSetValueForMode.mockClear();

await updateVariables({
collection,
mode: '1:1',
theme: tabletTheme,
tokens: tabletTokens,
settings: settingsWithAlias,
overallConfig: { tablet: TokenSetStatus.ENABLED },
});

// Verify that 1rem was converted to 15px (1 * 15)
expect(mockSetValueForMode).toHaveBeenCalledWith('1:1', 15);
// Verify that 2rem was converted to 30px (2 * 15)
expect(mockSetValueForMode).toHaveBeenCalledWith('1:1', 30);
});
});
Loading
Loading