From 9b410e8438b0219cd4ecb5b9964f7a7a17b71e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 01:30:07 +0000 Subject: [PATCH 1/2] feat: add Figma color variables support with source method selector - Add variableTokens to SelectionData interface - Extract color variables from node boundVariables in plugin.ts and plugin.network.ts - Create SourceSelector component (Local Styles / Variables / Both) - Filter displayed tokens in app.tsx based on selected source method - Update TokensList empty state messages to reflect selected source https://claude.ai/code/session_017mURqdtUiGEVEQUn42FPPV --- src/common/networkSides.ts | 1 + src/plugin/plugin.network.ts | 74 +++++++--- src/plugin/plugin.ts | 130 ++++++++++-------- src/ui/app.tsx | 18 ++- .../components/molecules/SourceSelector.tsx | 37 +++++ src/ui/components/organisms/TokensList.tsx | 30 +++- 6 files changed, 207 insertions(+), 83 deletions(-) create mode 100644 src/ui/components/molecules/SourceSelector.tsx diff --git a/src/common/networkSides.ts b/src/common/networkSides.ts index 18ede3e..8f272b6 100644 --- a/src/common/networkSides.ts +++ b/src/common/networkSides.ts @@ -17,6 +17,7 @@ export interface SelectionData { hasSelection: boolean; nodeCount: number; colorTokens: ColorToken[]; + variableTokens: ColorToken[]; } export const UI = Networker.createSide('UI-side').listens<{ diff --git a/src/plugin/plugin.network.ts b/src/plugin/plugin.network.ts index 109da7f..666c573 100644 --- a/src/plugin/plugin.network.ts +++ b/src/plugin/plugin.network.ts @@ -28,29 +28,19 @@ function getSelectionTokens(): SelectionData { return { hasSelection: false, nodeCount: 0, - colorTokens: [] + colorTokens: [], + variableTokens: [] }; } - // Use Figma's built-in API to get colors from selection const selectionColors = figma.getSelectionColors(); - if (!selectionColors || !selectionColors.styles) { - return { - hasSelection: true, - nodeCount: selection.length, - colorTokens: [] - }; - } - - const allTokens: ColorToken[] = []; - - // Process paint styles - if (selectionColors.styles && selectionColors.styles.length > 0) { + const styleTokens: ColorToken[] = []; + if (selectionColors && selectionColors.styles && selectionColors.styles.length > 0) { for (const style of selectionColors.styles) { const paint = style.paints[0]; if (paint && paint.type === 'SOLID') { - allTokens.push({ + styleTokens.push({ id: style.id, name: style.name, type: 'STYLE', @@ -66,10 +56,62 @@ function getSelectionTokens(): SelectionData { } } + const variableTokens: ColorToken[] = []; + const seenVariableIds = new Set(); + + function collectVariablesFromNode(node: SceneNode) { + if ('boundVariables' in node && node.boundVariables) { + const boundVars = node.boundVariables as Record; + for (const aliasOrArray of Object.values(boundVars)) { + const aliases = Array.isArray(aliasOrArray) ? aliasOrArray : [aliasOrArray]; + for (const alias of aliases) { + if (alias && alias.type === 'VARIABLE_ALIAS' && !seenVariableIds.has(alias.id)) { + seenVariableIds.add(alias.id); + try { + const variable = figma.variables.getVariableById(alias.id); + if (variable && variable.resolvedType === 'COLOR') { + const modeIds = Object.keys(variable.valuesByMode); + if (modeIds.length > 0) { + const value = variable.valuesByMode[modeIds[0]] as RGBA; + if (value && typeof value === 'object' && 'r' in value) { + variableTokens.push({ + id: variable.id, + name: variable.name, + type: 'VARIABLE', + color: { + r: value.r, + g: value.g, + b: value.b, + a: value.a ?? 1 + }, + hex: rgbToHex(value.r, value.g, value.b) + }); + } + } + } + } catch (e) { + console.log('Error processing variable id:', alias.id, e); + } + } + } + } + } + if ('children' in node) { + for (const child of (node as ChildrenMixin).children) { + collectVariablesFromNode(child as SceneNode); + } + } + } + + for (const node of selection) { + collectVariablesFromNode(node); + } + return { hasSelection: true, nodeCount: selection.length, - colorTokens: allTokens + colorTokens: styleTokens, + variableTokens }; } diff --git a/src/plugin/plugin.ts b/src/plugin/plugin.ts index a378b6b..6367e70 100644 --- a/src/plugin/plugin.ts +++ b/src/plugin/plugin.ts @@ -21,7 +21,8 @@ async function bootstrap() { PLUGIN_CHANNEL.emit(UI, "selectionUpdate", [{ hasSelection: false, nodeCount: 0, - colorTokens: [] + colorTokens: [], + variableTokens: [] }]); } else { // Request tokens for current selection @@ -40,35 +41,26 @@ async function bootstrap() { return { hasSelection: false, nodeCount: 0, - colorTokens: [] + colorTokens: [], + variableTokens: [] }; } - + // Use Figma's built-in API to get colors from selection const selectionColors = figma.getSelectionColors(); console.log('Selection colors:', selectionColors); - - if (!selectionColors || !selectionColors.styles) { - console.log('No colors found in selection'); - return { - hasSelection: true, - nodeCount: selection.length, - colorTokens: [] - }; - } - - const allTokens: any[] = []; - - // Process paint styles - if (selectionColors.styles && selectionColors.styles.length > 0) { + + const styleTokens: any[] = []; + + if (selectionColors && selectionColors.styles && selectionColors.styles.length > 0) { console.log('Found', selectionColors.styles.length, 'paint styles in selection'); - + for (const style of selectionColors.styles) { console.log('Processing style:', style.name, style.id); - + const paint = style.paints[0]; if (paint && paint.type === "SOLID") { - allTokens.push({ + styleTokens.push({ id: style.id, name: style.name, type: "STYLE", @@ -80,52 +72,70 @@ async function bootstrap() { }, hex: rgbToHex(paint.color.r, paint.color.g, paint.color.b) }); - console.log('Added paint style:', style.name, 'hex:', rgbToHex(paint.color.r, paint.color.g, paint.color.b)); } } } - - // Process variables if they exist (commented out temporarily) - // if (selectionColors.variables && selectionColors.variables.length > 0) { - // console.log('Found', selectionColors.variables.length, 'variables in selection'); - - // for (const variable of selectionColors.variables) { - // console.log('Processing variable:', variable.name, variable.id); - // - // try { - // const collection = figma.variables.getVariableCollectionById(variable.variableCollectionId); - // if (collection) { - // const mode = collection.defaultModeId; - // const value = variable.valuesByMode[mode]; - // - // if (value && typeof value === 'object' && 'r' in value) { - // allTokens.push({ - // id: variable.id, - // name: variable.name, - // type: "VARIABLE", - // color: { - // r: value.r, - // g: value.g, - // b: value.b, - // a: value.a ?? 1 - // }, - // hex: rgbToHex(value.r, value.g, value.b) - // }); - // console.log('Added color variable:', variable.name, 'hex:', rgbToHex(value.r, value.g, value.b)); - // } - // } - // } catch (e) { - // console.log('Error processing variable:', variable.name, e); - // } - // } - // } - - console.log('Final results - Tokens found:', allTokens.length); - + + // Extract color variables from the selection + const variableTokens: any[] = []; + const seenVariableIds = new Set(); + + function collectVariablesFromNode(node: SceneNode) { + if ('boundVariables' in node && node.boundVariables) { + const boundVars = node.boundVariables as Record; + for (const aliasOrArray of Object.values(boundVars)) { + const aliases = Array.isArray(aliasOrArray) ? aliasOrArray : [aliasOrArray]; + for (const alias of aliases) { + if (alias && alias.type === 'VARIABLE_ALIAS' && !seenVariableIds.has(alias.id)) { + seenVariableIds.add(alias.id); + try { + const variable = figma.variables.getVariableById(alias.id); + if (variable && variable.resolvedType === 'COLOR') { + const modeIds = Object.keys(variable.valuesByMode); + if (modeIds.length > 0) { + const rawValue = variable.valuesByMode[modeIds[0]]; + const value = rawValue as RGBA; + if (value && typeof value === 'object' && 'r' in value) { + variableTokens.push({ + id: variable.id, + name: variable.name, + type: 'VARIABLE', + color: { + r: value.r, + g: value.g, + b: value.b, + a: value.a ?? 1 + }, + hex: rgbToHex(value.r, value.g, value.b) + }); + } + } + } + } catch (e) { + console.log('Error processing variable id:', alias.id, e); + } + } + } + } + } + if ('children' in node) { + for (const child of (node as ChildrenMixin).children) { + collectVariablesFromNode(child as SceneNode); + } + } + } + + for (const node of selection) { + collectVariablesFromNode(node); + } + + console.log('Final results - Style tokens:', styleTokens.length, 'Variable tokens:', variableTokens.length); + return { hasSelection: true, nodeCount: selection.length, - colorTokens: allTokens + colorTokens: styleTokens, + variableTokens }; } diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 1d031ad..426dd7a 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { AppHeader } from './components/organisms/AppHeader'; import { EmptyState } from './components/organisms/EmptyState'; import { TokensList } from './components/organisms/TokensList'; +import { SourceSelector, SourceMethod } from './components/molecules/SourceSelector'; import '@ui/styles/main.css'; @@ -11,8 +12,10 @@ function App() { const [selectionData, setSelectionData] = useState({ hasSelection: false, nodeCount: 0, - colorTokens: [] + colorTokens: [], + variableTokens: [] }); + const [sourceMethod, setSourceMethod] = useState('both'); useEffect(() => { UI_CHANNEL.subscribe('selectionUpdate', (data: SelectionData) => { @@ -20,17 +23,28 @@ function App() { }); }, []); + const displayedTokens = selectionData.hasSelection + ? sourceMethod === 'styles' + ? selectionData.colorTokens + : sourceMethod === 'variables' + ? selectionData.variableTokens + : [...selectionData.colorTokens, ...selectionData.variableTokens] + : []; + return (
+ + {!selectionData.hasSelection ? ( ) : ( )}
diff --git a/src/ui/components/molecules/SourceSelector.tsx b/src/ui/components/molecules/SourceSelector.tsx new file mode 100644 index 0000000..836a617 --- /dev/null +++ b/src/ui/components/molecules/SourceSelector.tsx @@ -0,0 +1,37 @@ +import { OptionButton } from './OptionButton'; + +export type SourceMethod = 'styles' | 'variables' | 'both'; + +interface SourceSelectorProps { + value: SourceMethod; + onChange: (method: SourceMethod) => void; +} + +const OPTIONS: { value: SourceMethod; label: string; description: string }[] = [ + { value: 'styles', label: 'Local Styles', description: 'Extract from local paint styles' }, + { value: 'variables', label: 'Variables', description: 'Extract from color variables' }, + { value: 'both', label: 'Both', description: 'Extract from styles and variables' }, +]; + +function SourceSelector({ value, onChange }: SourceSelectorProps) { + return ( +
+

+ Token Source +

+
+ {OPTIONS.map(opt => ( + onChange(opt.value)} + /> + ))} +
+
+ ); +} + +export { SourceSelector }; diff --git a/src/ui/components/organisms/TokensList.tsx b/src/ui/components/organisms/TokensList.tsx index ec45c8a..82bd5b2 100644 --- a/src/ui/components/organisms/TokensList.tsx +++ b/src/ui/components/organisms/TokensList.tsx @@ -3,13 +3,33 @@ import { TokenCard } from '../molecules/TokenCard'; import { EmptyStateIcon } from '../molecules/EmptyStateIcon'; import { ExportSection } from './ExportSection'; import { ColorToken } from '@common/networkSides'; +import { SourceMethod } from '../molecules/SourceSelector'; interface TokensListProps { tokens: ColorToken[]; nodeCount: number; + sourceMethod: SourceMethod; } -function TokensList({ tokens, nodeCount }: TokensListProps) { +const EMPTY_HINTS: Record = { + styles: [ + '• Elements with local color styles applied', + '• Frames or groups containing styled elements', + '• Components with color tokens', + ], + variables: [ + '• Elements with color variables bound to fills or strokes', + '• Frames or groups containing variable-bound elements', + '• Components using color variables', + ], + both: [ + '• Elements with local color styles or color variables', + '• Frames or groups containing styled elements', + '• Components with color tokens or variables', + ], +}; + +function TokensList({ tokens, nodeCount, sourceMethod }: TokensListProps) { if (tokens.length === 0) { return (
@@ -20,15 +40,15 @@ function TokensList({ tokens, nodeCount }: TokensListProps) {

The selected {nodeCount} node{nodeCount !== 1 ? 's' : ''} don't use - local color styles. + any color {sourceMethod === 'styles' ? 'styles' : sourceMethod === 'variables' ? 'variables' : 'styles or variables'}.

Try selecting:

    -
  • • Elements with local color styles applied
  • -
  • • Frames or groups containing styled elements
  • -
  • • Components with color tokens
  • + {EMPTY_HINTS[sourceMethod].map(hint => ( +
  • {hint}
  • + ))}
From 4854836c74f0b391d54cc77de41619ccc9c092d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 01:30:31 +0000 Subject: [PATCH 2/2] chore: update package-lock.json https://claude.ai/code/session_017mURqdtUiGEVEQUn42FPPV --- package-lock.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24278c8..fefdac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2573,7 +2573,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4045,7 +4045,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -4706,7 +4706,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4760,7 +4760,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4799,7 +4799,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.12.0" } @@ -5625,7 +5625,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "devOptional": true, + "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -6105,7 +6105,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8.6" }, @@ -7234,7 +7234,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, + "dev": true, "dependencies": { "is-number": "^7.0.0" },