Andrés Moya 3cd9bfa9de
✨ Add sync with design tokens in external libraries (#10293)
* ✨ Auto link tokens when adding external libraries (provisional)

* 🔧 Refactor tokens-lib initialization

* 🔧 Add separated TokenStatus to store status apart of TokensLib

* 🔧 Make all status operations use the new data structure

* 🔧 Normalize status helper functions and access token sets by id

* 🔧 Rename :tokens-file to :tokens-source

* 🎉 Allow the user to choose the tokens-source of a file

* 🎉 Make tokens library readonly when it's in an external file

* 🎉 Show tokens in library summaries

* 🎉 Show source info in sidebar

* 🔧 Fix integration tests

* 🐛 Propagate changes of token values in external library

* 🎉 Layout updates

* 🔧 Refactor tokens source calculations

* 🔧 Add harder checks for nil or empty values in everything

* 🐛 Fix some integration tests

* 🔧 Add integration tests for tokens in external libs

* 🔧 Validate and repair missing tokens status

* 🎉 Make ui changes optional with config flag

* 🐛 Propagate tokens after synchronizing components in ext library

* 🐛 Propagate tokens after creating new instances

* 🐛 Propagate tokens after synchronizing tokens in ext library

* 🐛 Add a tokens source icon to libraries section (#11439)

* 🐛 Add a tokens source icon to libraries section

* 🐛 Fix ellipsis on library names

* ♻️ Remove code under flag on legacy component

* 🐛 Fix token theme name on inspect tab

* 🎉 Add changes notification (#11476)

* 🎉 Add changes notification

* ♻️ Change fn names

* 🐛 Fix tokens source label truncation and missing translations (#11533)

* 🐛 Fix tokens source label truncation and missing translations

The tokens source file name always showed, even for the current file,
and long names wrapped onto a second line instead of truncating
because the header used flex-wrap and overflow-wrap: break-word
instead of single-line ellipsis.

Show the source row unconditionally (it now displays "This file" when
the source is the current file, matching the connected-library case),
truncate the file name to one line with an ellipsis, and only attach a
tooltip with the full name when the text is actually truncated.

Replace the hardcoded UI strings with translated ones and add their
English and Spanish entries.

AI-assisted-by: claude-sonnet-5

* 🐛 Remove redundant effect dependency in tokens source

file-name-truncated? was listed as a dependency of the with-effect
that checks and observes label truncation, even though it isn't
read inside the effect body. Since the effect itself flips that
state via check-file-name-truncated, including it as a dependency
caused the ResizeObserver to be needlessly disconnected and
reconnected on every truncation change.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix small visual error

* 🐛 Fix problem with plugins

* 🐛 Fix playwright tests

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-21 15:37:45 +02:00

296 lines
6.8 KiB
TypeScript

import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { fromEvent, map, filter, merge, of } from 'rxjs';
import { PluginMessageEvent, PluginUIEvent } from '../model';
type TokenTheme = {
id: string;
name: string;
group: string;
description: string;
active: boolean;
activeSets: string;
};
type TokenSet = {
id: string;
name: string;
description: string;
active: boolean;
};
type Token = {
id: string;
name: string;
description: string;
valueString: string;
resolvedValueString: string;
};
type TokensGroup = [string, Token[]];
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.css',
host: {
'[attr.data-theme]': 'theme()',
},
})
export class AppComponent {
public messages$ = fromEvent<MessageEvent<PluginMessageEvent>>(
window,
'message',
);
public initialTheme$ = of(
new URLSearchParams(window.location.search).get('theme'),
).pipe(filter((theme) => !!theme));
public theme = toSignal(
merge(
this.initialTheme$,
this.messages$.pipe(
filter((event) => event.data.type === 'theme'),
map((event) => {
return event.data.content;
}),
),
),
);
public isEditableTokens: boolean = false;
public themes: TokenTheme[] = [];
public sets: TokenSet[] = [];
public tokenGroups: TokensGroup[] = [];
public currentSetId: string | undefined = undefined;
constructor() {
window.addEventListener('message', (event) => {
if (event.data.type === 'set-themes') {
this.#setThemes(event.data.themesData);
} else if (event.data.type === 'set-sets') {
this.#setSets(event.data.setsData);
} else if (event.data.type === 'set-tokens') {
this.#setTokens(event.data.tokenGroupsData);
} else if (event.data.type === 'set-is-editable-tokens') {
this.#setIsEditableTokens(event.data.isEditableTokens);
}
});
}
loadLibrary() {
this.#sendMessage({ type: 'load-library' });
}
loadTokens(setId: string) {
this.currentSetId = setId;
this.#sendMessage({ type: 'load-tokens', setId });
}
addTheme() {
this.#sendMessage({
type: 'add-theme',
themeGroup: this.#randomString(),
themeName: this.#randomString(),
});
}
addSet() {
this.#sendMessage({ type: 'add-set', setName: this.#randomString() });
}
addToken(tokenType: string) {
let tokenValue;
switch (tokenType) {
case 'borderRadius':
tokenValue = '25';
break;
case 'shadow':
tokenValue = [
{
color: '#123456',
inset: 'false',
offsetX: '6',
offsetY: '6',
spread: '0',
blur: '4',
},
];
break;
case 'color':
tokenValue = '#fabada';
break;
case 'dimension':
tokenValue = '100';
break;
case 'fontFamilies':
tokenValue = ['Source Sans Pro', 'Sans serif'];
break;
case 'fontSizes':
tokenValue = '24';
break;
case 'fontWeights':
tokenValue = 'bold';
break;
case 'letterSpacing':
tokenValue = '0.5';
break;
case 'number':
tokenValue = '33';
break;
case 'opacity':
tokenValue = '0.6';
break;
case 'rotation':
tokenValue = '45';
break;
case 'sizing':
tokenValue = '200';
break;
case 'spacing':
tokenValue = '16';
break;
case 'borderWidth':
tokenValue = '3';
break;
case 'textCase':
tokenValue = 'lowercase';
break;
case 'textDecoration':
tokenValue = 'underline';
break;
case 'typography':
tokenValue = {
fontFamilies: ['Acme', 'Arial', 'Sans Serif'],
fontSizes: '36',
letterSpacing: '0.8',
textCase: 'uppercase',
textDecoration: 'none',
fontWeights: '600',
lineHeight: '1.5',
};
break;
}
if (this.currentSetId && tokenValue) {
this.#sendMessage({
type: 'add-token',
setId: this.currentSetId,
tokenType,
tokenName: this.#randomString(),
tokenValue,
});
} else {
console.log('Invalid token type');
}
}
renameTheme(themeId: string, themeName: string) {
const newName = prompt('Rename theme', themeName);
if (newName && newName !== '') {
this.#sendMessage({ type: 'rename-theme', themeId, newName });
}
}
renameSet(setId: string, setName: string) {
const newName = prompt('Rename set', setName);
if (newName && newName !== '') {
this.#sendMessage({ type: 'rename-set', setId, newName });
}
}
renameToken(tokenId: string, tokenName: string) {
const newName = prompt('Rename token', tokenName);
if (this.currentSetId && newName && newName !== '') {
this.#sendMessage({
type: 'rename-token',
setId: this.currentSetId,
tokenId,
newName,
});
}
}
deleteTheme(themeId: string) {
this.#sendMessage({ type: 'delete-theme', themeId });
}
deleteSet(setId: string) {
this.#sendMessage({ type: 'delete-set', setId });
}
deleteToken(tokenId: string) {
if (this.currentSetId) {
this.#sendMessage({
type: 'delete-token',
setId: this.currentSetId,
tokenId,
});
}
}
isThemeActive(themeId: string) {
for (const theme of this.themes) {
if (theme.id === themeId) {
return theme.active;
}
}
return false;
}
toggleTheme(themeId: string) {
this.#sendMessage({ type: 'toggle-theme', themeId });
}
isSetActive(setId: string) {
for (const set of this.sets) {
if (set.id === setId) {
return set.active;
}
}
return false;
}
toggleSet(setId: string) {
this.#sendMessage({ type: 'toggle-set', setId });
}
applyToken(tokenId: string) {
if (this.currentSetId) {
this.#sendMessage({
type: 'apply-token',
setId: this.currentSetId,
tokenId,
// properties: ['borderRadiusTopRight'] // Uncomment to choose attribute to apply
}); // (incompatible attributes will have no effect)
}
}
#sendMessage(message: PluginUIEvent) {
parent.postMessage(message, '*');
}
#setIsEditableTokens(isEditableTokens: boolean) {
this.isEditableTokens = isEditableTokens;
}
#setThemes(themes: TokenTheme[]) {
this.themes = themes;
}
#setSets(sets: TokenSet[]) {
this.sets = sets;
}
#setTokens(tokenGroups: TokensGroup[]) {
this.tokenGroups = tokenGroups;
}
#randomString() {
// Generate a big random number and convert it to string using base 36
// (the number of letters in the ascii alphabet)
return Math.floor(Math.random() * Date.now()).toString(36);
}
}