Alonso Torres e3b8b006db
🐛 Fix font family space in tokens (#11750)
* 🐛 Fix font family space in tokens

* 🐛 Resolve token merge conflicts

Keep the font-family token fix and the newer
background-plugin change together in the
unreleased changelog and plugin API test suite.

AI-assisted-by: gpt-6-luna
---------

Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com>
2026-10-15 10:05:44 +02:00

942 lines
31 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type {
TokenCatalog,
TokenColor,
TokenSet,
TokenShadow,
TokenType,
TokenTypography,
} from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
import { waitFor } from './wait';
// Design tokens.
// The token catalog is reached through the local library. Sets/themes/tokens are
// self-provisioned; sets are created active so token references resolve.
function catalog(ctx: TestContext): TokenCatalog {
return ctx.penpot.library.local.tokens;
}
function activeSet(ctx: TestContext, name: string): TokenSet {
return catalog(ctx).addSet({ name, active: true });
}
// Names must be unique across runs too: sets/themes leak into the file (the
// API has no theme remove and set removal is best-effort), so a plain counter
// collides with leftovers from a previous run. Add a per-run random tag.
const runTag = Math.random().toString(36).slice(2, 8);
let counter = 0;
function unique(prefix: string): string {
counter += 1;
return `${prefix}-${runTag}-${counter}`;
}
async function rectangleWithStrokeTokens(ctx: TestContext) {
const set = activeSet(ctx, unique('stroke-set'));
const width = set.addToken({
type: 'borderWidth',
name: unique('border.width'),
value: '4',
});
const color = set.addToken({
type: 'color',
name: unique('border.color'),
value: '#112233',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.strokes = [
{ strokeColor: '#000000', strokeWidth: 1 },
{ strokeColor: '#ffffff', strokeWidth: 2 },
];
rect.applyToken(width, ['strokeWidth']);
await waitFor(() => rect.tokens.strokeWidth === width.name);
rect.applyToken(color, ['strokeColor']);
await waitFor(() => rect.tokens.strokeColor === color.name);
expect(rect.tokens).toEqual({
strokeColor: color.name,
strokeWidth: width.name,
strokeWidthTop: width.name,
strokeWidthRight: width.name,
strokeWidthBottom: width.name,
strokeWidthLeft: width.name,
});
return { rect, width, color };
}
/** Token application and theme/set wiring update the store asynchronously. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('Tokens', () => {
describe('Catalog', () => {
test('addSet creates a token set', (ctx) => {
const cat = catalog(ctx);
const set = cat.addSet({ name: unique('set'), active: true });
expect(typeof set.id).toBe('string');
expect(set.active).toBe(true);
expect(cat.sets.length).toBeGreaterThan(0);
expect(cat.getSetById(set.id)).toBeDefined();
});
test('addTheme creates a token theme', (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
expect(typeof theme.id).toBe('string');
expect(cat.themes.length).toBeGreaterThan(0);
expect(cat.getThemeById(theme.id)).toBeDefined();
});
test('catalog name validation uses current state and rejects blank names', (ctx) => {
const cat = catalog(ctx);
const name = unique('live-set');
cat.addSet({ name });
expect(() => cat.addSet({ name })).toThrow();
expect(() => cat.addSet({ name: ' ' })).toThrow();
expect(() => cat.addTheme({ group: '', name: ' ' })).toThrow();
const group = unique('live-theme-group');
const themeName = unique('live-theme');
cat.addTheme({ group, name: themeName });
expect(() => cat.addTheme({ group, name: themeName })).toThrow();
});
});
describe('Set', () => {
test('name and active round-trip', (ctx) => {
const set = activeSet(ctx, unique('set'));
const newName = unique('renamed');
set.name = newName;
expect(set.name).toBe(newName);
set.active = false;
expect(set.active).toBe(false);
set.toggleActive();
expect(set.active).toBe(true);
});
test('retained set proxies validate names against current state', (ctx) => {
const first = activeSet(ctx, unique('set'));
const second = activeSet(ctx, unique('set'));
expect(() => {
first.name = second.name;
}).toThrow();
expect(() => {
first.name = ' ';
}).toThrow();
});
// Community report (forum #10700, issue #14): toggling a token set's
// active state was said to freeze and roll back when tokens from the set
// are bound to shapes. Did not reproduce; kept as a regression pin.
test('toggleActive persists when the set has bound tokens', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderRadius',
name: unique('radius.'),
value: '12',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.applyToken(token);
await sleep(300);
set.toggleActive();
await waitFor(() => set.active === false);
expect(set.active).toBe(false);
set.toggleActive();
await waitFor(() => set.active === true);
expect(set.active).toBe(true);
});
test('addToken adds a token and lists it', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#ff0000',
});
expect(typeof token.id).toBe('string');
expect(set.tokens.length).toBeGreaterThan(0);
expect(Array.isArray(set.tokensByType)).toBe(true);
expect(set.getTokenById(token.id)).toBeDefined();
});
test('duplicate and remove a set', (ctx) => {
const set = activeSet(ctx, unique('set'));
const dup = set.duplicate();
expect(dup).not.toBeNull();
expect(dup.id).not.toBe(set.id);
dup.remove();
});
// Invalid input — addToken must reject bad input.
test('empty token name throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({ type: 'color', name: '', value: '#ff0000' }),
).toThrow();
});
test('duplicate token name in the same set throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
const name = unique('color.dup');
set.addToken({ type: 'color', name, value: '#ff0000' });
expect(() =>
set.addToken({ type: 'color', name, value: '#0000ff' }),
).toThrow();
});
test('opacity token value outside 0..1 throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({ type: 'opacity', name: unique('op.'), value: '2' }),
).toThrow();
});
test('invalid token type throws', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({
type: 'not-a-type' as unknown as TokenType,
name: unique('bad.'),
value: '1',
}),
).toThrow();
});
test('empty composites and line-height without font-size throw', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({
type: 'typography',
name: unique('empty-typography.'),
value: {} as never,
}),
).toThrow();
expect(() =>
set.addToken({
type: 'shadow',
name: unique('empty-shadow.'),
value: [] as never,
}),
).toThrow();
expect(() =>
set.addToken({
type: 'typography',
name: unique('line-height-only.'),
value: { lineHeight: '1.2' } as never,
}),
).toThrow();
});
});
describe('Theme', () => {
test('group, name and active round-trip', (ctx) => {
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
const group = unique('brand');
const name = unique('dark');
theme.group = group;
theme.name = name;
expect(theme.group).toBe(group);
expect(theme.name).toBe(name);
theme.active = true;
expect(theme.active).toBe(true);
theme.toggleActive();
});
test('addSet and removeSet manage the theme sets', async (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
const set = cat.addSet({ name: unique('set'), active: false });
theme.addSet(set);
await waitFor(() => theme.activeSets.length > 0);
expect(theme.activeSets.length).toBeGreaterThan(0);
theme.removeSet(set);
});
// addSet/removeSet also accept a token set id (string), not just a TokenSet.
test('addSet and removeSet accept a set id', async (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
const set = cat.addSet({ name: unique('set'), active: false });
theme.addSet(set.id);
await waitFor(() => theme.activeSets.length > 0);
expect(theme.activeSets.length).toBeGreaterThan(0);
theme.removeSet(set.id);
await waitFor(() => theme.activeSets.length === 0);
expect(theme.activeSets.length).toBe(0);
});
test('duplicate and remove a theme', (ctx) => {
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
const dup = theme.duplicate();
expect(dup.id).not.toBe(theme.id);
dup.remove();
});
test('theme names stay unique within their current group', (ctx) => {
const cat = catalog(ctx);
const group = unique('group');
const first = cat.addTheme({ group, name: unique('theme') });
const second = cat.addTheme({ group, name: unique('theme') });
expect(() => {
first.name = second.name;
}).toThrow();
expect(() => {
first.name = ' ';
}).toThrow();
const duplicate = first.duplicate();
expect(duplicate.name).not.toBe(first.name);
duplicate.remove();
});
test('moving a retained theme cannot create a group/name collision', (ctx) => {
const cat = catalog(ctx);
const name = unique('shared-theme');
const first = cat.addTheme({ group: unique('group-a'), name });
const second = cat.addTheme({ group: unique('group-b'), name });
expect(() => {
first.group = second.group;
}).toThrow();
});
});
describe('Token', () => {
test('base properties round-trip', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#00ff00',
});
token.description = 'a token';
expect(token.description).toBe('a token');
expect(typeof token.id).toBe('string');
// resolvedValueString resolves against active sets.
expect(token.resolvedValueString).toBeDefined();
});
test('color token exposes type and value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#123456',
});
expect(token.type).toBe('color');
expect(token.value).toBe('#123456');
});
test('dimension and number tokens expose resolved values', (ctx) => {
const set = activeSet(ctx, unique('set'));
const dim = set.addToken({
type: 'dimension',
name: unique('dim.'),
value: '16',
});
const num = set.addToken({
type: 'rotation',
name: unique('rot.'),
value: '2',
});
expect(dim.type).toBe('dimension');
expect(num.type).toBe('rotation');
if (dim.type === 'dimension') {
expect(dim.resolvedValue).toBeCloseTo(16, 0);
}
});
test('applyToShapes applies a token to a shape', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderRadius',
name: unique('radius.'),
value: '8',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
token.applyToShapes([rect]);
await waitFor(() => Object.keys(rect.tokens).length > 0);
expect(Object.keys(rect.tokens).length).toBeGreaterThan(0);
});
test('applyToSelected applies a token to the selection', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'opacity',
name: unique('opacity.'),
value: '0.5',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
token.applyToSelected();
await waitFor(() => Object.keys(rect.tokens).length > 0);
expect(Object.keys(rect.tokens).length).toBeGreaterThan(0);
});
test('applyToken applies a token through the shape', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderRadius',
name: unique('radius.'),
value: '12',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.applyToken(token);
expect(rect.tokens).toBeDefined();
});
// Community report (forum #10700, issue #9): applyToken with a spacing token
// on 'paddingLeft' once recorded the binding under a bogus
// 'paddingBottomLeft' key (the gap properties bound correctly). Fixed; kept
// as a regression pin.
test('applyToken binds a spacing token to flex padding', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'spacing',
name: unique('spacing.'),
value: '8',
});
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
b.resize(300, 200);
b.addFlexLayout();
b.applyToken(token, ['columnGap']);
b.applyToken(token, ['paddingLeft']);
// Both bindings settle together; wait on columnGap, then assert paddingLeft
// lands under its own key (the previously-misfiled case).
await waitFor(() => Object.keys(b.tokens).includes('columnGap'));
expect(Object.keys(b.tokens)).toContain('columnGap');
expect(Object.keys(b.tokens)).toContain('paddingLeft');
});
// The target is a text shape because the attribute set of a board excludes
// the font-family property, so the application is filtered out there and
// the assertions pass vacuously.
// The family comes from the bundled Google fonts catalog, available in every
// run. `Red` in its name reads as a color if the resolver parses the words.
test('applyToken binds a fontFamilies token to a text shape', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const font = ctx.penpot.fonts.findByName('Red Hat Display');
if (!font) throw new Error('Red Hat Display font not found');
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: [font.fontFamily],
});
const t = ctx.penpot.createText('Hello Penpot');
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
t.applyToken(token, ['fontFamilies']);
await waitFor(() => t.fontFamily === font.fontFamily);
expect(Object.keys(t.tokens)).toContain('fontFamilies');
expect(t.fontFamily).toBe(font.fontFamily);
});
test('applyToken accepts the uniform strokeWidth property', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderWidth',
name: unique('width.'),
value: '4',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.applyToken(token, ['strokeWidth']);
await waitFor(() => Object.keys(rect.tokens).length > 0);
const keys = Object.keys(rect.tokens);
expect(keys).toContain('strokeWidthTop');
expect(keys).toContain('strokeWidthRight');
expect(keys).toContain('strokeWidthBottom');
expect(keys).toContain('strokeWidthLeft');
});
test('applyToken binds a single per-side stroke width property', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderWidth',
name: unique('width.'),
value: '4',
});
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.applyToken(token, ['strokeWidthTop']);
await waitFor(() => Object.keys(rect.tokens).includes('strokeWidthTop'));
const keys = Object.keys(rect.tokens);
expect(keys).toContain('strokeWidthTop');
expect(keys).not.toContain('strokeWidthRight');
});
for (const side of [
'strokeWidthTop',
'strokeWidthRight',
'strokeWidthBottom',
'strokeWidthLeft',
] as const) {
test(`${side} edits preserve unrelated stroke tokens`, async (ctx) => {
const { rect } = await rectangleWithStrokeTokens(ctx);
const expected: Partial<typeof rect.tokens> = { ...rect.tokens };
delete expected[side];
delete expected.strokeWidth;
rect.strokes[0][side] = 8;
await ctx.penpot.waitForLayoutUpdate();
expect(rect.strokes[0][side]).toBe(8);
expect(rect.tokens).toEqual(expected);
});
}
test('uniform stroke width edits preserve the color token', async (ctx) => {
const { rect, color } = await rectangleWithStrokeTokens(ctx);
rect.strokes[0].strokeWidth = 8;
await ctx.penpot.waitForLayoutUpdate();
expect(rect.tokens).toEqual({ strokeColor: color.name });
expect(rect.strokes[0].strokeWidthType).toBe('simple');
expect(rect.strokes[0].strokeWidthTop).toBe(8);
expect(rect.strokes[0].strokeWidthRight).toBe(8);
expect(rect.strokes[0].strokeWidthBottom).toBe(8);
expect(rect.strokes[0].strokeWidthLeft).toBe(8);
});
test('stroke color edits preserve the width tokens', async (ctx) => {
const { rect } = await rectangleWithStrokeTokens(ctx);
const expected: Partial<typeof rect.tokens> = { ...rect.tokens };
delete expected.strokeColor;
rect.strokes[0].strokeColor = '#ff0000';
await ctx.penpot.waitForLayoutUpdate();
expect(rect.strokes[0].strokeColor).toBe('#ff0000');
expect(rect.tokens).toEqual(expected);
});
test('later stroke edits preserve the first stroke tokens', async (ctx) => {
const { rect } = await rectangleWithStrokeTokens(ctx);
const expected = { ...rect.tokens };
rect.strokes[1].strokeWidthTop = 8;
rect.strokes[1].strokeColor = '#ff0000';
await ctx.penpot.waitForLayoutUpdate();
expect(rect.strokes[1].strokeWidthTop).toBe(8);
expect(rect.strokes[1].strokeColor).toBe('#ff0000');
expect(rect.strokes[0].strokeWidthTop).toBe(4);
expect(rect.strokes[0].strokeColor).toBe('#112233');
expect(rect.tokens).toEqual(expected);
});
test('duplicate and remove a token', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'color',
name: unique('color.'),
value: '#abcdef',
});
const dup = token.duplicate();
expect(dup.id).not.toBe(token.id);
expect(dup.name).not.toBe(token.name);
dup.remove();
});
test('token edits reject missing, self, cyclic, and dotted-name conflicts', (ctx) => {
const set = activeSet(ctx, unique('set'));
const prefix = unique('tree');
set.addToken({
type: 'dimension',
name: `${prefix}.child`,
value: '8',
});
const token = set.addToken({
type: 'dimension',
name: unique('editable.'),
value: '4',
});
expect(() => {
token.name = prefix;
}).toThrow();
expect(() => {
token.value = `{${token.name}}`;
}).toThrow();
expect(() => {
token.value = '{missing-token}';
}).toThrow();
const other = set.addToken({
type: 'dimension',
name: unique('other.'),
value: '2',
});
token.value = `{${other.name}}`;
expect(() => {
other.value = `{${token.name}}`;
}).toThrow();
});
test('retained token proxies validate names against current state', (ctx) => {
const set = activeSet(ctx, unique('set'));
const retained = set.addToken({
type: 'dimension',
name: unique('retained.'),
value: '1',
});
const later = set.addToken({
type: 'dimension',
name: unique('later.'),
value: '2',
});
expect(() => {
retained.name = later.name;
}).toThrow();
});
test('renaming validates the whole token and rejects a new self-reference', (ctx) => {
const targetName = unique('rename-target');
activeSet(ctx, unique('target-set')).addToken({
type: 'dimension',
name: targetName,
value: '8',
});
const token = activeSet(ctx, unique('source-set')).addToken({
type: 'dimension',
name: unique('rename-source'),
value: `{${targetName}}`,
});
expect(() => {
token.name = targetName;
}).toThrow();
});
test('composite token updates reject empty values and missing font size', (ctx) => {
const set = activeSet(ctx, unique('set'));
const typography = set.addToken({
type: 'typography',
name: unique('typography.'),
value: { fontSizes: '14', lineHeight: '1.2' } as never,
}) as TokenTypography;
const shadow = set.addToken({
type: 'shadow',
name: unique('shadow.'),
value: {
color: '#000000',
inset: 'false',
offsetX: '0',
offsetY: '0',
spread: '0',
blur: '1',
},
}) as TokenShadow;
expect(() => {
typography.value = {} as never;
}).toThrow();
expect(() => {
typography.value = { lineHeight: '1.2' } as never;
}).toThrow();
expect(() => {
shadow.value = [];
}).toThrow();
});
test('tokens with newly broken references cannot be applied', (ctx) => {
const set = activeSet(ctx, unique('set'));
const base = set.addToken({
type: 'borderRadius',
name: unique('base.'),
value: '8',
});
const ref = set.addToken({
type: 'borderRadius',
name: unique('ref.'),
value: `{${base.name}}`,
});
base.remove();
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
expect(() => ref.applyToShapes([rect])).toThrow();
expect(() => ref.applyToSelected()).toThrow();
expect(() => rect.applyToken(ref)).toThrow();
});
// Reference resolution — a token referencing another resolves transitively.
test('a token referencing another token resolves transitively', (ctx) => {
const set = activeSet(ctx, unique('set'));
const baseName = unique('dim.base');
set.addToken({ type: 'dimension', name: baseName, value: '16' });
const ref = set.addToken({
type: 'dimension',
name: unique('dim.ref'),
value: `{${baseName}}`,
});
if (ref.type === 'dimension') {
expect(ref.resolvedValue).toBeCloseTo(16, 0);
}
});
});
});
// Every token type and the composite value types.
describe('Token types', () => {
const simpleCases: [TokenType, string, string][] = [
['borderRadius', '8', '12'],
['color', '#ff0000', '#00ff00'],
['dimension', '16', '24'],
['fontFamilies', 'Arial', 'Helvetica'],
['fontSizes', '14', '18'],
['fontWeights', '700', '400'],
['letterSpacing', '2', '3'],
['number', '3', '4'],
['opacity', '0.5', '0.8'],
['rotation', '45', '90'],
['sizing', '100', '120'],
['spacing', '8', '12'],
['borderWidth', '2', '3'],
['textCase', 'uppercase', 'lowercase'],
['textDecoration', 'underline', 'none'],
];
for (const [type, value, value2] of simpleCases) {
test(`${type} token exposes type, value and resolvedValue`, (ctx) => {
const set = activeSet(ctx, unique('set'));
// Cast to a concrete variant for property access; the recorder attributes
// members to the real runtime type via the `type` discriminant.
const token = set.addToken({
type,
name: unique(`${type}.`),
value,
}) as TokenColor;
expect(typeof token.type).toBe('string');
void token.value;
token.value = value2;
const renamed = unique('renamed.');
token.name = renamed;
// The value and name setters round-trip on the live backend. A
// `fontFamilies` value reads back as a family array, so join it before
// comparing to the assigned single family.
const readValue = token.value;
const normalized = Array.isArray(readValue)
? readValue.join(', ')
: readValue;
expect(normalized).toBe(value2);
expect(token.name).toBe(renamed);
// Record the resolvedValue (get) target for every type; the fontFamilies
// shape is asserted in the dedicated test below.
void token.resolvedValue;
});
}
// A fontFamilies token's `resolvedValue` is the resolved family list
// (`string[] | undefined`, e.g. ['Arial']). The binding used to leak the raw
// tokenscript list symbol; it now returns the documented array.
test('fontFamilies token resolvedValue is the family list', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: 'Arial',
});
const resolved = token.resolvedValue;
expect(Array.isArray(resolved)).toBe(true);
expect(resolved as unknown as string[]).toContain('Arial');
});
// The resolver parses a family whose name has several words as a list of
// word symbols, so each family name must survive as a single entry. A
// single-word family takes the simpler path the test above covers.
test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: ['Hanken Grotesk'],
});
// The stored value keeps the family whole, isolating resolution below.
expect(token.value).toEqual(['Hanken Grotesk']);
const resolved = token.resolvedValue as unknown as string[];
expect(Array.isArray(resolved)).toBe(true);
expect(resolved).toHaveLength(1);
expect(resolved[0]).toBe('Hanken Grotesk');
});
// Words like `Red` or `Black` read as colors and `2P` as a number and a word
// when the resolver parses the family name.
test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => {
const set = activeSet(ctx, unique('set'));
const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P'];
const token = set.addToken({
type: 'fontFamilies',
name: unique('fontFamilies.'),
value: families,
});
expect(token.resolvedValue as unknown as string[]).toEqual(families);
});
test('shadow token exposes its composite value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'shadow',
name: unique('shadow.'),
value: {
color: '#000000',
inset: 'false',
offsetX: '1',
offsetY: '2',
spread: '0',
blur: '4',
},
}) as TokenShadow;
expect(token.type).toBe('shadow');
// Round-trip the value (covers TokenShadow.value get + set) without changing
// it — the setter validates against the token's value schema, so assigning
// back exactly what the getter returned is guaranteed valid.
const v = token.value;
token.value = v;
if (typeof v !== 'string' && v.length > 0) {
const sv = v[0];
void sv.color;
void sv.inset;
void sv.offsetX;
void sv.offsetY;
void sv.spread;
void sv.blur;
sv.color = '#111111';
sv.inset = 'true';
sv.offsetX = '3';
sv.offsetY = '4';
sv.spread = '1';
sv.blur = '5';
}
// resolvedValue resolves the composite into a TokenShadowValue[]; each entry
// exposes the shadow members with their resolved (unit-converted) values.
const rv = token.resolvedValue;
expect(Array.isArray(rv)).toBe(true);
expect(rv).toBeDefined();
if (rv && rv.length > 0) {
const s = rv[0];
expect(s.color).toBe('#000000');
expect(s.inset).toBe(false);
expect(s.offsetX).toBeCloseTo(1, 0);
expect(s.offsetY).toBeCloseTo(2, 0);
expect(s.spread).toBeCloseTo(0, 0);
expect(s.blur).toBeCloseTo(4, 0);
// Exercise the writable members (records the set targets).
s.color = '#222222';
s.inset = true;
s.offsetX = 9;
s.offsetY = 8;
s.spread = 2;
s.blur = 6;
}
});
test('typography token exposes its composite value', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'typography',
name: unique('typo.'),
value: {
letterSpacing: '1',
fontFamilies: 'Arial',
fontSizes: '14',
fontWeight: '400',
lineHeight: '1.2',
textCase: 'none',
textDecoration: 'none',
},
}) as TokenTypography;
expect(token.type).toBe('typography');
const v = token.value;
if (typeof v !== 'string') {
void v.letterSpacing;
void v.fontFamilies;
void v.fontSizes;
void v.fontWeight;
void v.lineHeight;
void v.textCase;
void v.textDecoration;
v.letterSpacing = '2';
v.fontFamilies = 'Helvetica';
v.fontSizes = '16';
v.fontWeight = '700';
v.lineHeight = '1.5';
v.textCase = 'uppercase';
v.textDecoration = 'underline';
}
// resolvedValue resolves the composite into a TokenTypographyValue[]; each
// entry exposes the typography members with their resolved (unit-converted)
// values.
const rv = token.resolvedValue;
expect(Array.isArray(rv)).toBe(true);
expect(rv).toBeDefined();
if (rv && rv.length > 0) {
const t = rv[0];
expect(t.fontSizes).toBeCloseTo(14, 0);
expect(t.letterSpacing).toBeCloseTo(1, 0);
expect(t.lineHeight).toBeCloseTo(1.2, 1);
expect(Array.isArray(t.fontFamilies)).toBe(true);
expect(t.fontFamilies).toContain('Arial');
expect(typeof t.fontWeights).toBe('string');
expect(t.textCase).toBe('none');
expect(t.textDecoration).toBe('none');
// Exercise the writable members (records the set targets).
t.letterSpacing = 3;
t.fontFamilies = ['Helvetica'];
t.fontSizes = 18;
t.fontWeights = '500';
t.lineHeight = 2;
t.textCase = 'lowercase';
t.textDecoration = 'line-through';
}
token.value = {
letterSpacing: '2',
fontFamilies: 'Helvetica',
fontSizes: '16',
fontWeight: '700',
lineHeight: '1.5',
textCase: 'uppercase',
textDecoration: 'underline',
};
});
test('a token set can be removed', (ctx) => {
const cat = catalog(ctx);
const set = cat.addSet({ name: unique('set'), active: true });
set.remove();
});
test('theme externalId, activeSets and removeSet', (ctx) => {
const cat = catalog(ctx);
const theme = cat.addTheme({ group: '', name: unique('theme') });
const set = cat.addSet({ name: unique('set'), active: false });
void theme.externalId;
// theme.activeSets has no runtime setter (declared writable) — API bug.
void theme.activeSets;
theme.addSet(set);
theme.removeSet(set);
expect(typeof theme.id).toBe('string');
});
});