Skip to content
Merged
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
8 changes: 7 additions & 1 deletion packages/mcp/src/tools/get-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ export const getStylesTool: ToolSpec = {
description:
"Return the document's local styles grouped as { paints, texts, effects, grids }. " +
'Paint styles carry their paints; text styles carry fontName / fontSize / lineHeight / letterSpacing / textWrapStyle; ' +
'effect styles carry their effects; grid styles carry their layout grids.',
'effect styles carry their effects; grid styles carry their layout grids. ' +
'Any value the designer bound to a variable carries a `boundVariables` map ({ field: variableId }) ' +
'on the object that owns it — the individual paint, gradient stop, effect or layout grid, and the ' +
'text style itself for typography. A bound value is a reference, not a literal: emit the token, ' +
'not the resolved colour/number sitting next to it. `variables` maps every referenced id to its ' +
'{ name, type, codeSyntax? } (ids stay the key because variable names collide across ' +
'collections); it is omitted when nothing in the document is bound.',
inputSchema: z.object({}),
kind: 'read',
};
69 changes: 69 additions & 0 deletions packages/mcp/test/e2e/read-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,75 @@ describe('e2e get_styles', () => {
expect(result.paints[0]?.name).toBe('Primary');
expect(result).toEqual(response);
});

// The bindings a style carries are nested string maps two and three levels down, plus a
// result-level lookup table — shapes the wire (msgpack over the relay) had never carried before
// issue #164. A unit test on the handler cannot say they survive the round trip.
it('carries per-object variable bindings and the variables table over the wire', async () => {
const h = await startLeader();
harnesses.push(h);
const response: GetStylesResult = {
paints: [
{
id: 'S:1',
name: 'Brand/Primary',
key: 'k1',
description: '',
paints: [
{
type: 'SOLID',
visible: true,
opacity: 1,
color: { r: 0.1, g: 0.2, b: 0.3 },
boundVariables: { color: 'VariableID:1' },
},
],
},
],
texts: [],
effects: [
{
id: 'S:3',
name: 'Elevation/focus',
key: 'k3',
description: '',
effects: [
{
type: 'DROP_SHADOW',
visible: true,
radius: 24,
color: { r: 0.729, g: 0.839, b: 0.898, a: 1 },
offset: { x: 0, y: 4 },
spread: 4,
boundVariables: { color: 'VariableID:1', radius: 'VariableID:2' },
},
],
},
],
grids: [],
variables: {
'VariableID:1': { name: 'color/information', type: 'COLOR' },
'VariableID:2': { name: 'size/lg', type: 'FLOAT', codeSyntax: { WEB: '--size-lg' } },
},
};
sockets.push(
await connectFakePlugin({
port: h.port,
handlers: { [GET_STYLES_TOOL_NAME]: () => response },
}),
);
const result = (await dispatchTool(
{ node: h.node, follower: h.follower },
GET_STYLES_TOOL_NAME,
{},
)) as GetStylesResult;
expect(result).toEqual(response);
expect(result.effects[0]?.effects[0]?.boundVariables).toEqual({
color: 'VariableID:1',
radius: 'VariableID:2',
});
expect(result.variables?.['VariableID:1']?.name).toBe('color/information');
});
});

describe('e2e get_variable_defs', () => {
Expand Down
117 changes: 103 additions & 14 deletions packages/plugin/src/handlers/get-styles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,89 @@
import type { GetStylesResult, SerializedLineHeight } from '@figwright/shared';
import type {
GetStylesResult,
ResolvedToken,
SerializedBindings,
SerializedLineHeight,
SerializedTextStyle,
} from '@figwright/shared';

import type { SandboxToolHandler } from '../dispatcher.js';
import { serializeEffect, serializeLayoutGrid, serializePaint } from '../serializer.js';
import {
collectBindings,
serializeCodeSyntax,
serializeEffect,
serializeLayoutGrid,
serializePaint,
} from '../serializer.js';

const serializeLineHeight = (lh: LineHeight): SerializedLineHeight =>
lh.unit === 'AUTO' ? { unit: 'AUTO' } : { unit: lh.unit, value: lh.value };

/**
* Every variable id the serialized styles reference. Bindings ride on the individual paint /
* gradient stop / effect / layout grid (Figma keeps them there, not on the style), except for a
* text style, whose values are scalars and so binds on the style itself.
*
* A paint / effect / grid style ALSO exposes a style-level `boundVariables` (`{ paints | effects |
* layoutGrids: VariableAlias[] }`), which is deliberately not read: measured against a live file it
* is a flat list of whichever variables happen to be bound somewhere in the array — it says neither
* which paint/effect nor which field — so it is a strictly lossy summary of what the per-object
* bindings already carry exactly.
*/
const collectVariableIds = (result: GetStylesResult): Set<string> => {
const ids = new Set<string>();
const add = (bindings: SerializedBindings | undefined): void => {
if (bindings !== undefined) for (const id of Object.values(bindings)) ids.add(id);
};
for (const style of result.paints) {
for (const paint of style.paints) {
if (paint.type === 'SOLID') add(paint.boundVariables);
else if ('gradientStops' in paint) for (const s of paint.gradientStops) add(s.boundVariables);
}
}
for (const style of result.texts) add(style.boundVariables);
for (const style of result.effects) for (const e of style.effects) add(e.boundVariables);
for (const style of result.grids) for (const g of style.grids) add(g.boundVariables);
return ids;
};

/**
* Resolve variable ids → names, mirroring get_design_context's `variables` table so both grounding
* surfaces speak the same shape. An id that no longer resolves is skipped rather than fatal: the
* inline value stays as the fallback, exactly as it does today.
*/
const resolveVariables = async (
figmaCtx: typeof figma,
ids: ReadonlySet<string>,
): Promise<Record<string, ResolvedToken> | undefined> => {
const getVar = figmaCtx.variables?.getVariableByIdAsync;
if (ids.size === 0 || typeof getVar !== 'function') return undefined;
const ordered = [...ids];
// Resolved in parallel but assembled in walk order: writing each id as its promise settles would
// key the table by whichever lookup finished first, so the same document could serialize two
// different byte sequences on two runs.
const resolved = await Promise.all(
ordered.map(async (id): Promise<ResolvedToken | undefined> => {
try {
const v = await getVar.call(figmaCtx.variables, id);
if (v === null) return undefined;
const token: ResolvedToken = { name: v.name, type: v.resolvedType };
const codeSyntax = serializeCodeSyntax((v as { codeSyntax?: unknown }).codeSyntax);
if (codeSyntax !== undefined) token.codeSyntax = codeSyntax;
return token;
} catch {
/* unresolved ref — skip, the inline value remains the fallback */
return undefined;
}
}),
);
const variables: Record<string, ResolvedToken> = {};
for (const [i, id] of ordered.entries()) {
const token = resolved[i];
if (token !== undefined) variables[id] = token;
}
return Object.keys(variables).length > 0 ? variables : undefined;
};

export const createGetStylesHandler =
(figmaCtx: typeof figma): SandboxToolHandler =>
async () => {
Expand All @@ -24,17 +102,24 @@ export const createGetStylesHandler =
description: s.description,
paints: s.paints.map(serializePaint),
})),
texts: textStyles.map(s => ({
id: s.id,
name: s.name,
key: s.key,
description: s.description,
fontName: { family: s.fontName.family, style: s.fontName.style },
fontSize: s.fontSize,
lineHeight: serializeLineHeight(s.lineHeight),
letterSpacing: { unit: s.letterSpacing.unit, value: s.letterSpacing.value },
textWrapStyle: s.textWrapStyle,
})),
texts: textStyles.map(s => {
const style: SerializedTextStyle = {
id: s.id,
name: s.name,
key: s.key,
description: s.description,
fontName: { family: s.fontName.family, style: s.fontName.style },
fontSize: s.fontSize,
lineHeight: serializeLineHeight(s.lineHeight),
letterSpacing: { unit: s.letterSpacing.unit, value: s.letterSpacing.value },
textWrapStyle: s.textWrapStyle,
};
// Typography values are scalars, so a text style is the only place its bindings can live —
// unlike a paint / effect / grid style, where they sit on the individual object.
const bound = collectBindings(s.boundVariables);
if (bound !== undefined) style.boundVariables = bound;
return style;
}),
effects: effectStyles.map(s => ({
id: s.id,
name: s.name,
Expand All @@ -50,5 +135,9 @@ export const createGetStylesHandler =
grids: s.layoutGrids.map(serializeLayoutGrid),
})),
};
return result;
// Ids are only worth resolving once the styles are serialized — that's what says which
// variables this document's styles actually reference. The table goes FIRST so a reader meets
// `VariableID:5:12` already knowing what it names, instead of after every style that cites it.
const variables = await resolveVariables(figmaCtx, collectVariableIds(result));
return variables === undefined ? result : { variables, ...result };
};
57 changes: 52 additions & 5 deletions packages/plugin/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
MIXED,
type SerializedAnnotation,
type SerializedAutoLayout,
type SerializedBindings,
type SerializedColorStop,
type SerializedComponentProperty,
type SerializedEffect,
type SerializedGridChild,
Expand Down Expand Up @@ -41,15 +43,39 @@ const hasImageAdjustments = (filters: unknown): boolean =>
filters !== null &&
Object.values(filters).some(v => typeof v === 'number' && v !== 0);

/**
* A paint / effect / layout-grid / gradient-stop `boundVariables` record → `field` → variable id.
* Figma keeps these bindings on the object itself (not in the owning node's `boundVariables`), and
* each field binds exactly one variable — so this is the single-alias counterpart to
* `collectBoundVariables` below. Field names pass through as Figma reports them, so a newly
* bindable field is carried rather than silently dropped. Returns undefined when nothing is bound,
* keeping unbound paints/effects byte-identical to before.
*/
export const collectBindings = (raw: unknown): SerializedBindings | undefined => {
if (typeof raw !== 'object' || raw === null) return undefined;
const out: SerializedBindings = {};
for (const [field, alias] of Object.entries(raw)) {
if (typeof alias === 'object' && alias !== null && 'id' in alias) {
const id = (alias as { id: unknown }).id;
if (typeof id === 'string') out[field] = id;
}
}
return Object.keys(out).length > 0 ? out : undefined;
};

export const serializePaint = (paint: Paint): SerializedPaint => {
const visible = paint.visible ?? true;
const opacity = paint.opacity ?? 1;
if (paint.type === 'SOLID') {
// A bound colour lives on the paint, not on the owning node — carry it, or a variable-driven
// fill is indistinguishable from a hard-coded one downstream (issue #164).
const bound = collectBindings(paint.boundVariables);
return {
type: 'SOLID',
visible,
opacity,
color: { r: paint.color.r, g: paint.color.g, b: paint.color.b },
...(bound === undefined ? {} : { boundVariables: bound }),
};
}
if (isGradient(paint)) {
Expand All @@ -64,10 +90,16 @@ export const serializePaint = (paint: Paint): SerializedPaint => {
type: paint.type,
visible,
opacity,
gradientStops: stops.map(s => ({
position: s.position,
color: { r: s.color.r, g: s.color.g, b: s.color.b, a: s.color.a },
})),
gradientStops: stops.map(s => {
const stop: SerializedColorStop = {
position: s.position,
color: { r: s.color.r, g: s.color.g, b: s.color.b, a: s.color.a },
};
// Each stop binds its own colour, so the binding has to ride on the stop, not the paint.
const bound = collectBindings(s.boundVariables);
if (bound !== undefined) stop.boundVariables = bound;
return stop;
}),
gradientTransform: transform.map(row => row.slice()),
};
}
Expand Down Expand Up @@ -746,6 +778,9 @@ export const serializeTree = async (node: SceneNode): Promise<SerializedNode> =>
};

export const serializeEffect = (effect: Effect): SerializedEffect => {
// Shadow bindings (colour / radius / spread / offsetX / offsetY) live on the effect itself; a
// shadow whose colour is a variable otherwise reads as a hard-coded RGBA (issue #164).
const bound = collectBindings((effect as { boundVariables?: unknown }).boundVariables);
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
return {
type: effect.type,
Expand All @@ -754,11 +789,13 @@ export const serializeEffect = (effect: Effect): SerializedEffect => {
color: { r: effect.color.r, g: effect.color.g, b: effect.color.b, a: effect.color.a },
offset: { x: effect.offset.x, y: effect.offset.y },
spread: effect.spread ?? 0,
...(bound === undefined ? {} : { boundVariables: bound }),
};
}
// Blurs / textures carry radius; noise / glass carry only type + visible.
const out: SerializedEffect = { type: effect.type, visible: effect.visible };
if ('radius' in effect && typeof effect.radius === 'number') out.radius = effect.radius;
if (bound !== undefined) out.boundVariables = bound;
return out;
};

Expand All @@ -778,8 +815,15 @@ export const serializeCodeSyntax = (raw: unknown): Record<string, string> | unde
};

export const serializeLayoutGrid = (grid: LayoutGrid): SerializedLayoutGrid => {
// sectionSize / count / offset / gutterSize can each be variable-bound, on the grid object.
const bound = collectBindings((grid as { boundVariables?: unknown }).boundVariables);
if (grid.pattern === 'GRID') {
return { pattern: 'GRID', visible: grid.visible ?? true, sectionSize: grid.sectionSize };
return {
pattern: 'GRID',
visible: grid.visible ?? true,
sectionSize: grid.sectionSize,
...(bound === undefined ? {} : { boundVariables: bound }),
};
}
const out: SerializedLayoutGrid = {
pattern: grid.pattern,
Expand All @@ -794,5 +838,8 @@ export const serializeLayoutGrid = (grid: LayoutGrid): SerializedLayoutGrid => {
if (typeof grid.offset === 'number' && grid.offset !== 0 && grid.alignment !== 'CENTER') {
out.offset = grid.offset;
}
// Last, so a bound grid reads as "the grid, then what it binds" — the order every other
// serializer here emits.
if (bound !== undefined) out.boundVariables = bound;
return out;
};
Loading