Skip to content

Commit 6e33c28

Browse files
committed
feat: implement color mixing simplification and enhance gradient handling in styles
1 parent 9cb3aa4 commit 6e33c28

File tree

4 files changed

+243
-10
lines changed

4 files changed

+243
-10
lines changed

packages/extension/mcp/tools/code/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MCP_MAX_PAYLOAD_BYTES } from '@tempad-dev/mcp-shared'
44

55
import { activePlugin } from '@/ui/state'
66
import { stringifyComponent } from '@/utils/component'
7+
import { simplifyColorMixToRgba } from '@/utils/css'
78

89
import type { CodeLanguage, RenderContext } from './render'
910

@@ -183,6 +184,7 @@ export async function handleGetCode(
183184
canonicalById
184185
)
185186
code = replaceTokensWithValues(code, resolvedByFinal)
187+
code = simplifyColorMixToRgba(code)
186188
if (code.length > MAX_CODE_CHARS) {
187189
code = code.slice(0, MAX_CODE_CHARS)
188190
truncated = true

packages/extension/mcp/tools/code/styles/background.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
formatHexAlpha,
44
parseBackgroundShorthand,
55
preprocessCssValue,
6-
stripFallback
6+
stripFallback,
7+
normalizeFigmaVarName
78
} from '@/utils/css'
89

910
const BG_URL_LIGHTGRAY_RE = /url\(.*?\)\s+lightgray/i
@@ -19,6 +20,12 @@ export function cleanFigmaSpecificStyles(
1920
const bgValue = processed.background
2021
const normalized = stripFallback(preprocessCssValue(bgValue)).trim()
2122

23+
const gradient = resolveGradientWithOpacity(normalized, node)
24+
if (gradient) {
25+
processed.background = gradient
26+
return processed
27+
}
28+
2229
if (isSolidBackground(normalized)) {
2330
processed['background-color'] = normalized
2431
delete processed.background
@@ -62,6 +69,131 @@ export function cleanFigmaSpecificStyles(
6269
return processed
6370
}
6471

72+
function resolveGradientWithOpacity(value: string, node: SceneNode): string | null {
73+
if (!value) return null
74+
if (!/gradient\(/i.test(value)) return null
75+
if (!('fills' in node) || !Array.isArray(node.fills)) return null
76+
77+
const fill = node.fills.find((f) => f && f.visible !== false && f.type === 'GRADIENT_LINEAR') as
78+
| GradientPaint
79+
| undefined
80+
if (!fill || !Array.isArray(fill.gradientStops)) return null
81+
82+
const fillOpacity = typeof fill.opacity === 'number' ? fill.opacity : 1
83+
const hasStopAlpha = fill.gradientStops.some((stop) => (stop.color?.a ?? 1) < 1)
84+
if (fillOpacity >= 0.99 && !hasStopAlpha) return null
85+
86+
const parsed = parseGradient(value)
87+
if (!parsed) return null
88+
89+
const angle = parsed.args[0]?.trim()
90+
const hasAngle =
91+
!!angle &&
92+
(angle.endsWith('deg') ||
93+
angle.endsWith('rad') ||
94+
angle.endsWith('turn') ||
95+
angle.startsWith('to '))
96+
const stops = fill.gradientStops.map((stop) => {
97+
const pct = formatPercent(stop.position)
98+
const color = formatGradientStopColor(stop, fillOpacity)
99+
return `${color} ${pct}`
100+
})
101+
102+
const args = hasAngle ? [angle, ...stops] : stops
103+
return `${parsed.fn}(${args.join(', ')})`
104+
}
105+
106+
function parseGradient(value: string): { fn: string; args: string[] } | null {
107+
const match = value.match(/(linear-gradient|radial-gradient|conic-gradient)\s*\(/i)
108+
if (!match || match.index == null) return null
109+
const fn = match[1]
110+
const start = value.indexOf('(', match.index)
111+
if (start < 0) return null
112+
113+
let depth = 0
114+
let end = -1
115+
for (let i = start; i < value.length; i += 1) {
116+
const ch = value[i]
117+
if (ch === '(') depth += 1
118+
else if (ch === ')') {
119+
depth -= 1
120+
if (depth === 0) {
121+
end = i
122+
break
123+
}
124+
}
125+
}
126+
if (end < 0) return null
127+
128+
const inner = value.slice(start + 1, end)
129+
return { fn, args: splitTopLevel(inner) }
130+
}
131+
132+
function splitTopLevel(input: string): string[] {
133+
const out: string[] = []
134+
let depth = 0
135+
let quote: '"' | "'" | null = null
136+
let buf = ''
137+
138+
for (let i = 0; i < input.length; i += 1) {
139+
const ch = input[i]
140+
141+
if (quote) {
142+
if (ch === '\\') {
143+
buf += ch
144+
i += 1
145+
if (i < input.length) buf += input[i]
146+
continue
147+
}
148+
if (ch === quote) quote = null
149+
buf += ch
150+
continue
151+
}
152+
153+
if (ch === '"' || ch === "'") {
154+
quote = ch
155+
buf += ch
156+
continue
157+
}
158+
159+
if (ch === '(') depth += 1
160+
if (ch === ')') depth = Math.max(0, depth - 1)
161+
162+
if (ch === ',' && depth === 0) {
163+
out.push(buf.trim())
164+
buf = ''
165+
continue
166+
}
167+
168+
buf += ch
169+
}
170+
171+
if (buf.trim()) out.push(buf.trim())
172+
return out
173+
}
174+
175+
function formatPercent(pos: number): string {
176+
const pct = Math.round(pos * 10000) / 100
177+
return `${pct}%`
178+
}
179+
180+
function formatGradientStopColor(stop: ColorStop, fillOpacity: number): string {
181+
const baseAlpha = stop.color?.a ?? 1
182+
const alpha = Math.max(0, Math.min(1, baseAlpha * fillOpacity))
183+
184+
const bound = stop.boundVariables?.color
185+
if (bound && typeof bound === 'object' && 'id' in bound && bound.id) {
186+
const v = figma.variables.getVariableById(bound.id)
187+
const name = normalizeFigmaVarName(v?.name ?? '')
188+
const expr = `var(${name})`
189+
if (alpha >= 0.99) return expr
190+
const pct = Math.round(alpha * 10000) / 100
191+
return `color-mix(in srgb, ${expr} ${pct}%, transparent)`
192+
}
193+
194+
return formatHexAlpha(stop.color, alpha)
195+
}
196+
65197
function isSolidBackground(value: string): boolean {
66198
if (!value) return false
67199
const trimmed = value.trim()

packages/extension/mcp/tools/token/mapping.ts

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function normalizeStyleVars(
2626
if (!mappings) return used
2727

2828
const syntaxMap = buildCodeSyntaxIndex(mappings.variableIds)
29+
const replaceMap = buildReplaceMap(mappings.variableIds)
2930

3031
for (const style of styles.values()) {
3132
for (const [prop, raw] of Object.entries(style)) {
@@ -37,19 +38,18 @@ export function normalizeStyleVars(
3738
if (rewrite) {
3839
style[prop] = `var(${rewrite.canonical})`
3940
used.add(rewrite.id)
41+
continue
4042
}
41-
}
42-
43-
for (const [prop, raw] of Object.entries(style)) {
44-
if (!raw) continue
45-
const value = raw.trim()
46-
if (!value) continue
4743

4844
const matched = syntaxMap.get(value)
49-
if (!matched) continue
45+
if (matched) {
46+
used.add(matched)
47+
style[prop] = `var(${normalizeFigmaVarName(value)})`
48+
continue
49+
}
5050

51-
used.add(matched)
52-
style[prop] = `var(${normalizeFigmaVarName(value)})`
51+
const replaced = replaceKnownNames(raw, replaceMap, used)
52+
if (replaced !== raw) style[prop] = replaced
5353
}
5454
}
5555

@@ -69,6 +69,69 @@ function buildCodeSyntaxIndex(variableIds: Set<string>): Map<string, string> {
6969
return map
7070
}
7171

72+
type ReplaceEntry = { name: string; normalized: string; id: string }
73+
74+
function buildReplaceMap(variableIds: Set<string>): ReplaceEntry[] {
75+
const entries: ReplaceEntry[] = []
76+
for (const id of variableIds) {
77+
const v = figma.variables.getVariableById(id)
78+
if (!v) continue
79+
80+
const normalizedName = normalizeFigmaVarName(v.name ?? '')
81+
if (normalizedName && normalizedName !== '--unnamed') {
82+
entries.push({ name: normalizedName, normalized: normalizedName, id })
83+
}
84+
85+
const cs = v.codeSyntax?.WEB?.trim()
86+
if (cs) {
87+
const normalized = normalizeFigmaVarName(cs)
88+
if (normalized && normalized !== '--unnamed') {
89+
entries.push({ name: cs, normalized, id })
90+
}
91+
}
92+
}
93+
94+
entries.sort((a, b) => b.name.length - a.name.length)
95+
return entries
96+
}
97+
98+
function replaceKnownNames(value: string, entries: ReplaceEntry[], used: Set<string>): string {
99+
if (!value || !entries.length) return value
100+
101+
const placeholders: string[] = []
102+
let out = replaceVarFunctions(value, ({ full }) => {
103+
const token = `__VAR_${placeholders.length}__`
104+
placeholders.push(full)
105+
return token
106+
})
107+
108+
let changed = false
109+
110+
for (const entry of entries) {
111+
const escaped = entry.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
112+
const re = new RegExp(`(^|[^A-Za-z0-9_-])(${escaped})(?=[^A-Za-z0-9_-]|$)`, 'g')
113+
out = out.replace(re, (_match, prefix: string, _name: string, offset: number, str: string) => {
114+
const start = offset + prefix.length
115+
const left = str.slice(0, start).replace(/\s+$/, '')
116+
if (left.toLowerCase().endsWith('var(')) {
117+
return _match
118+
}
119+
used.add(entry.id)
120+
changed = true
121+
return `${prefix}var(${entry.normalized})`
122+
})
123+
}
124+
125+
if (placeholders.length) {
126+
out = out.replace(/__VAR_(\d+)__/g, (_match, index: string) => {
127+
const i = Number(index)
128+
return Number.isFinite(i) ? placeholders[i] : _match
129+
})
130+
}
131+
132+
return changed ? out : value
133+
}
134+
72135
export async function applyPluginTransforms(
73136
markup: string,
74137
pluginCode: string | undefined,

packages/extension/utils/css.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,42 @@ export function normalizeStyleValues(
322322
return normalized
323323
}
324324

325+
export function simplifyColorMixToRgba(input: string): string {
326+
if (!input || !input.includes('color-mix(')) return input
327+
328+
return input.replace(
329+
/color-mix\(\s*in\s+srgb\s*,\s*(#[0-9a-fA-F]{3,8})\s+([0-9.]+)%\s*,\s*transparent\s*\)/g,
330+
(_match, hex: string, pct: string) => {
331+
const parsed = parseHexColor(hex)
332+
if (!parsed) return _match
333+
const weight = Number(pct) / 100
334+
if (!Number.isFinite(weight)) return _match
335+
const alpha = toDecimalPlace(parsed.a * weight, 3)
336+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`
337+
}
338+
)
339+
}
340+
341+
function parseHexColor(input: string): { r: number; g: number; b: number; a: number } | null {
342+
let hex = input.trim().replace(/^#/, '')
343+
if (![3, 4, 6, 8].includes(hex.length)) return null
344+
345+
if (hex.length === 3 || hex.length === 4) {
346+
hex = hex
347+
.split('')
348+
.map((c) => c + c)
349+
.join('')
350+
}
351+
352+
const r = parseInt(hex.slice(0, 2), 16)
353+
const g = parseInt(hex.slice(2, 4), 16)
354+
const b = parseInt(hex.slice(4, 6), 16)
355+
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
356+
357+
if (![r, g, b, a].every((v) => Number.isFinite(v))) return null
358+
return { r, g, b, a }
359+
}
360+
325361
function parseBorderShorthand(normalized: string): {
326362
width?: string
327363
style?: string

0 commit comments

Comments
 (0)