Skip to content
Draft
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/fix-group-description-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/figma-plugin": patch
---

Fix handling of $description at group and root level to comply with DTCG specification. Group and root level descriptions are no longer incorrectly converted to tokens with type "other". They are now properly preserved as metadata when provided through the API.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Metadata for a token set, including group-level descriptions
export type TokenSetMetadata = {
// Group path to its metadata (e.g., "colors.primary" -> { $description: "..." })
groups?: Record<string, GroupMetadata>;
// Root-level metadata
root?: GroupMetadata;
};

export type GroupMetadata = {
$description?: string;
$extensions?: Record<string, any>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { StorageType } from '../StorageType';
import { ThemeObjectsList } from '../ThemeObjectsList';
import { UsedTokenSetsMap } from '../UsedTokenSetsMap';
import { AnyTokenList } from './AnyTokenList';
import { TokenSetMetadata } from './TokenSetMetadata';

export type TokenStore = {
version: string;
updatedAt: string;
// @README these could be different themes or sets of tokens
values: Record<string, AnyTokenList>;
// Metadata for token sets (group-level descriptions, etc.)
metadata?: Record<string, TokenSetMetadata>;
usedTokenSet?: UsedTokenSetsMap | null;
checkForChanges?: boolean | null;
activeTheme: Record<string, string>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { TokenFormatOptions, TokenFormat } from '@/plugin/TokenFormatStoreClass';
import convertToTokenArray from './convertTokens';
import convertTokensToObject from './convertTokensToObject';

describe('Round-trip conversion with group descriptions', () => {
beforeEach(() => {
TokenFormat.setFormat(TokenFormatOptions.DTCG);
});

it('should preserve group and root descriptions through full round-trip', () => {
// Input JSON with group and root descriptions
const inputJSON = {
$description: 'Fluent Blue color palettes',
primary: {
$description: 'Primary brand colors',
10: {
$value: '#061724',
$type: 'color',
$description: 'Darkest primary color',
},
20: {
$value: '#0a2540',
$type: 'color',
},
},
secondary: {
$description: 'Secondary colors',
light: {
$value: '#f0f0f0',
$type: 'color',
},
dark: {
$value: '#333333',
$type: 'color',
},
},
};

// Step 1: Convert JSON to token array (what happens when loading)
const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON });

// Verify tokens are extracted correctly
expect(tokenArray).toHaveLength(4);
expect(tokenArray[0].name).toBe('primary.10');
expect(tokenArray[0].value).toBe('#061724');
expect(tokenArray[0].description).toBe('Darkest primary color');

// Verify metadata is captured
expect(metadata.root?.$description).toBe('Fluent Blue color palettes');
expect(metadata.groups?.['primary']?.$description).toBe('Primary brand colors');
expect(metadata.groups?.['secondary']?.$description).toBe('Secondary colors');

// Step 2: Convert back to nested object (what happens when exporting)
const reconstructed = convertTokensToObject(
{ base: tokenArray },
true,
{ base: metadata },
);

// Verify structure is preserved
expect(reconstructed.base.$description).toBe('Fluent Blue color palettes');
expect(reconstructed.base.primary.$description).toBe('Primary brand colors');
expect(reconstructed.base.primary['10'].$value).toBe('#061724');
expect(reconstructed.base.primary['10'].$description).toBe('Darkest primary color');
expect(reconstructed.base.primary['20'].$value).toBe('#0a2540');
expect(reconstructed.base.secondary.$description).toBe('Secondary colors');
expect(reconstructed.base.secondary.light.$value).toBe('#f0f0f0');
expect(reconstructed.base.secondary.dark.$value).toBe('#333333');
});

it('should handle nested groups with descriptions', () => {
const inputJSON = {
$description: 'Root description',
colors: {
$description: 'All colors',
brand: {
$description: 'Brand colors',
primary: {
$description: 'Primary brand',
base: {
$value: '#0000ff',
$type: 'color',
},
},
},
},
};

const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON });

expect(tokenArray).toHaveLength(1);
expect(tokenArray[0].name).toBe('colors.brand.primary.base');

expect(metadata.root?.$description).toBe('Root description');
expect(metadata.groups?.['colors']?.$description).toBe('All colors');
expect(metadata.groups?.['colors.brand']?.$description).toBe('Brand colors');
expect(metadata.groups?.['colors.brand.primary']?.$description).toBe('Primary brand');

// Reconstruct
const reconstructed = convertTokensToObject(
{ base: tokenArray },
true,
{ base: metadata },
);

expect(reconstructed.base.$description).toBe('Root description');
expect(reconstructed.base.colors.$description).toBe('All colors');
expect(reconstructed.base.colors.brand.$description).toBe('Brand colors');
expect(reconstructed.base.colors.brand.primary.$description).toBe('Primary brand');
expect(reconstructed.base.colors.brand.primary.base.$value).toBe('#0000ff');
});

it('should handle $extensions metadata', () => {
const inputJSON = {
$description: 'Root description',
$extensions: {
'com.example': {
version: '1.0',
},
},
primary: {
$description: 'Primary colors',
$extensions: {
'com.example': {
category: 'brand',
},
},
base: {
$value: '#ff0000',
$type: 'color',
},
},
};

const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON });

expect(metadata.root?.$description).toBe('Root description');
expect(metadata.root?.$extensions).toEqual({
'com.example': {
version: '1.0',
},
});
expect(metadata.groups?.['primary']?.$extensions).toEqual({
'com.example': {
category: 'brand',
},
});

// Reconstruct
const reconstructed = convertTokensToObject(
{ base: tokenArray },
true,
{ base: metadata },
);

expect(reconstructed.base.$description).toBe('Root description');
expect(reconstructed.base.$extensions).toEqual({
'com.example': {
version: '1.0',
},
});
expect(reconstructed.base.primary.$description).toBe('Primary colors');
expect(reconstructed.base.primary.$extensions).toEqual({
'com.example': {
category: 'brand',
},
});
});
});
60 changes: 58 additions & 2 deletions packages/tokens-studio-for-figma/src/utils/convertTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('convertToTokenArray', () => {
},
};

expect(convertToTokenArray({ tokens: basicTokens })).toEqual([
expect(convertToTokenArray({ tokens: basicTokens }).tokens).toEqual([
{ name: 'global.withValue', value: 'bar', type: 'other' },
{ name: 'global.basic', value: '#ff0000', type: 'other' },
{ ...typographyTokens.withValue.output, name: 'global.typography.heading.h2' },
Expand Down Expand Up @@ -118,7 +118,7 @@ describe('convertToTokenArray', () => {
expandShadow: true,
expandComposition: true,
expandBorder: true,
}),
}).tokens,
).toEqual([
{ name: 'global.withValue', value: 'bar', type: 'other' },
{ name: 'global.basic', value: '#ff0000', type: 'other' },
Expand All @@ -141,4 +141,60 @@ describe('convertToTokenArray', () => {
{ name: 'global.nestGroupWithType.font.big', value: '24px', type: 'dimension' },
]);
});

it('ignores group-level and root-level $description metadata', () => {
const tokensWithGroupDescriptions = {
$description: 'Root level description',
primary: {
$description: 'Primary brand colors',
10: {
$value: '#061724',
$type: 'color',
$description: 'Token level description',
},
20: {
$value: '#0a2540',
$type: 'color',
},
},
secondary: {
$description: 'Secondary colors',
light: {
$value: '#f0f0f0',
$type: 'color',
},
},
};

const result = convertToTokenArray({ tokens: tokensWithGroupDescriptions });

// Should only include actual tokens, not $description metadata
expect(result.tokens).toEqual([
{
name: 'primary.10',
value: '#061724',
type: 'color',
description: 'Token level description',
},
{
name: 'primary.20',
value: '#0a2540',
type: 'color',
},
{
name: 'secondary.light',
value: '#f0f0f0',
type: 'color',
},
]);

// Verify that $description is not treated as a token
const hasDescriptionToken = result.tokens.some((token) => token.name.includes('$description') || token.name.includes('description'));
expect(hasDescriptionToken).toBe(false);

// Verify metadata is captured correctly
expect(result.metadata.root?.$description).toBe('Root level description');
expect(result.metadata.groups?.['primary']?.$description).toBe('Primary brand colors');
expect(result.metadata.groups?.['secondary']?.$description).toBe('Secondary colors');
});
});
Loading