@su-record/vibe 2.8.47 → 2.8.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/scripts/figma-extract.js +141 -16
- package/package.json +1 -1
- package/skills/vibe.figma/SKILL.md +111 -884
- package/skills/vibe.figma.convert/SKILL.md +146 -27
- package/skills/vibe.figma.convert/rubrics/conversion-rules.md +16 -0
- package/skills/vibe.figma.extract/SKILL.md +56 -7
- package/skills/vibe.figma.extract/rubrics/image-rules.md +8 -0
|
@@ -103,6 +103,34 @@ function toCSS(c) {
|
|
|
103
103
|
return `rgba(${r}, ${g}, ${b}, ${+a.toFixed(2)})`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// ─── Gradient Helpers ───────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function toLinearGradient(f) {
|
|
109
|
+
if (!f.gradientHandlePositions || !f.gradientStops) return null;
|
|
110
|
+
const [p0, p1] = f.gradientHandlePositions;
|
|
111
|
+
const angle = Math.round(Math.atan2(p1.y - p0.y, p1.x - p0.x) * 180 / Math.PI + 90);
|
|
112
|
+
const opacity = f.opacity ?? 1;
|
|
113
|
+
const stops = f.gradientStops.map(s => {
|
|
114
|
+
const c = opacity < 1 ? { ...s.color, a: (s.color.a ?? 1) * opacity } : s.color;
|
|
115
|
+
return `${toCSS(c)} ${Math.round(s.position * 100)}%`;
|
|
116
|
+
}).join(', ');
|
|
117
|
+
return `linear-gradient(${angle}deg, ${stops})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toRadialGradient(f) {
|
|
121
|
+
if (!f.gradientStops) return null;
|
|
122
|
+
const opacity = f.opacity ?? 1;
|
|
123
|
+
const stops = f.gradientStops.map(s => {
|
|
124
|
+
const c = opacity < 1 ? { ...s.color, a: (s.color.a ?? 1) * opacity } : s.color;
|
|
125
|
+
return `${toCSS(c)} ${Math.round(s.position * 100)}%`;
|
|
126
|
+
}).join(', ');
|
|
127
|
+
return `radial-gradient(circle, ${stops})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Blend Mode Map ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const BLEND_MODES = { MULTIPLY:'multiply', SCREEN:'screen', OVERLAY:'overlay', DARKEN:'darken', LIGHTEN:'lighten', COLOR_DODGE:'color-dodge', COLOR_BURN:'color-burn', HARD_LIGHT:'hard-light', SOFT_LIGHT:'soft-light', DIFFERENCE:'difference', EXCLUSION:'exclusion', HUE:'hue', SATURATION:'saturation', COLOR:'color', LUMINOSITY:'luminosity' };
|
|
133
|
+
|
|
106
134
|
// ─── CSS Extraction ─────────────────────────────────────────────────
|
|
107
135
|
|
|
108
136
|
function extractCSS(n) {
|
|
@@ -115,38 +143,108 @@ function extractCSS(n) {
|
|
|
115
143
|
if (n.primaryAxisAlignItems && axM[n.primaryAxisAlignItems]) css.justifyContent = axM[n.primaryAxisAlignItems];
|
|
116
144
|
if (n.counterAxisAlignItems && crM[n.counterAxisAlignItems]) css.alignItems = crM[n.counterAxisAlignItems];
|
|
117
145
|
if (n.itemSpacing > 0) css.gap = `${n.itemSpacing}px`;
|
|
146
|
+
// layoutGrow
|
|
147
|
+
if (n.layoutGrow === 1) css.flexGrow = '1';
|
|
118
148
|
// Padding
|
|
119
149
|
const pt=n.paddingTop||0, pr=n.paddingRight||0, pb=n.paddingBottom||0, pl=n.paddingLeft||0;
|
|
120
150
|
if (pt||pr||pb||pl) css.padding = `${pt}px ${pr}px ${pb}px ${pl}px`;
|
|
121
151
|
// Size
|
|
122
152
|
if (n.absoluteBoundingBox) { css.width = `${Math.round(n.absoluteBoundingBox.width)}px`; css.height = `${Math.round(n.absoluteBoundingBox.height)}px`; }
|
|
153
|
+
// layoutSizing — HUG removes fixed dimensions, FILL handled by converter with parent context
|
|
154
|
+
if (n.layoutSizingHorizontal === 'HUG') delete css.width;
|
|
155
|
+
if (n.layoutSizingVertical === 'HUG') delete css.height;
|
|
123
156
|
// Position / overflow / opacity
|
|
124
157
|
if (n.layoutPositioning === 'ABSOLUTE') css.position = 'absolute';
|
|
125
158
|
if (n.clipsContent) css.overflow = 'hidden';
|
|
126
159
|
if (n.opacity != null && n.opacity < 1) css.opacity = n.opacity.toFixed(2);
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
// Rotation
|
|
161
|
+
if (n.rotation != null && Math.abs(n.rotation) > 0.05) {
|
|
162
|
+
const deg = +((-n.rotation) % 360).toFixed(2);
|
|
163
|
+
css.transform = `rotate(${deg}deg)`;
|
|
164
|
+
}
|
|
165
|
+
// Node-level blend mode
|
|
166
|
+
if (n.blendMode && BLEND_MODES[n.blendMode]) css.mixBlendMode = BLEND_MODES[n.blendMode];
|
|
130
167
|
// Radius
|
|
131
168
|
if (n.cornerRadius > 0) css.borderRadius = `${n.cornerRadius}px`;
|
|
132
169
|
else if (n.rectangleCornerRadii) { const [a,b,c,d] = n.rectangleCornerRadii; css.borderRadius = `${a}px ${b}px ${c}px ${d}px`; }
|
|
133
|
-
// Fills
|
|
134
|
-
let imgRef;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
// Fills — multi-fill aware
|
|
171
|
+
let imgRef, imgScaleMode;
|
|
172
|
+
const visibleFills = (n.fills||[]).filter(f=>f.visible!==false);
|
|
173
|
+
// Backward compat: single-fill css properties
|
|
174
|
+
const firstSolid = visibleFills.find(f=>f.type==='SOLID');
|
|
175
|
+
if (firstSolid) css.backgroundColor = toCSS({ ...firstSolid.color, a: firstSolid.opacity ?? firstSolid.color?.a ?? 1 });
|
|
176
|
+
const firstImage = visibleFills.find(f=>f.type==='IMAGE');
|
|
177
|
+
if (firstImage) { imgRef = firstImage.imageRef; imgScaleMode = firstImage.scaleMode; }
|
|
178
|
+
// Gradient → backgroundImage
|
|
179
|
+
const gradients = [];
|
|
180
|
+
for (const f of visibleFills) {
|
|
181
|
+
if (f.type === 'GRADIENT_LINEAR') { const g = toLinearGradient(f); if (g) gradients.push(g); }
|
|
182
|
+
else if (f.type === 'GRADIENT_RADIAL' || f.type === 'GRADIENT_ANGULAR' || f.type === 'GRADIENT_DIAMOND') {
|
|
183
|
+
const g = toRadialGradient(f); if (g) gradients.push(g);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (gradients.length) css.backgroundImage = gradients.join(', ');
|
|
187
|
+
// Per-fill blendMode → backgroundBlendMode
|
|
188
|
+
for (const f of visibleFills) {
|
|
189
|
+
if (f.blendMode && f.blendMode !== 'NORMAL' && BLEND_MODES[f.blendMode]) {
|
|
190
|
+
css.backgroundBlendMode = BLEND_MODES[f.blendMode]; break;
|
|
191
|
+
}
|
|
138
192
|
}
|
|
139
|
-
//
|
|
193
|
+
// Fill filters (saturation → grayscale/saturate)
|
|
194
|
+
for (const f of visibleFills) {
|
|
195
|
+
if (f.filters?.saturation != null && f.filters.saturation !== 0) {
|
|
196
|
+
const sat = f.filters.saturation;
|
|
197
|
+
const filterVal = sat < 0 ? `grayscale(${Math.round(Math.abs(sat) * 100)}%)` : `saturate(${Math.round((1 + sat) * 100)}%)`;
|
|
198
|
+
css.filter = (css.filter ? css.filter + ' ' : '') + filterVal;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Multi-fill structured array (when 2+ visible fills — for converter)
|
|
202
|
+
let _fills;
|
|
203
|
+
if (visibleFills.length > 1) {
|
|
204
|
+
_fills = visibleFills.map(f => {
|
|
205
|
+
const entry = { type: f.type };
|
|
206
|
+
if (f.type === 'SOLID') entry.color = toCSS({ ...f.color, a: f.opacity ?? f.color?.a ?? 1 });
|
|
207
|
+
if (f.type === 'IMAGE') { entry.imageRef = f.imageRef; if (f.scaleMode) entry.scaleMode = f.scaleMode; }
|
|
208
|
+
if (f.type?.startsWith('GRADIENT_')) {
|
|
209
|
+
entry.gradient = f.type === 'GRADIENT_LINEAR' ? toLinearGradient(f) : toRadialGradient(f);
|
|
210
|
+
}
|
|
211
|
+
if (f.blendMode && f.blendMode !== 'NORMAL' && BLEND_MODES[f.blendMode]) entry.blendMode = BLEND_MODES[f.blendMode];
|
|
212
|
+
if (f.filters?.saturation != null && f.filters.saturation !== 0) entry.filters = f.filters;
|
|
213
|
+
return entry;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Strokes — strokeAlign aware + individual strokes + dashes
|
|
140
217
|
const stroke = (n.strokes||[]).find(s=>s.visible!==false&&s.type==='SOLID');
|
|
141
|
-
|
|
218
|
+
const isw = n.individualStrokeWeights;
|
|
219
|
+
const hasStroke = stroke && (n.strokeWeight || isw);
|
|
220
|
+
if (hasStroke) {
|
|
221
|
+
const strokeColor = toCSS({ ...stroke.color, a: stroke.opacity ?? stroke.color?.a ?? 1 });
|
|
222
|
+
// Individual stroke weights (different per side)
|
|
223
|
+
if (isw && (isw.top !== isw.bottom || isw.left !== isw.right || isw.top !== isw.left)) {
|
|
224
|
+
if (isw.top) css.borderTop = `${isw.top}px solid ${strokeColor}`;
|
|
225
|
+
if (isw.right) css.borderRight = `${isw.right}px solid ${strokeColor}`;
|
|
226
|
+
if (isw.bottom) css.borderBottom = `${isw.bottom}px solid ${strokeColor}`;
|
|
227
|
+
if (isw.left) css.borderLeft = `${isw.left}px solid ${strokeColor}`;
|
|
228
|
+
} else if (n.strokeAlign === 'OUTSIDE') {
|
|
229
|
+
css.outline = `${n.strokeWeight}px solid ${strokeColor}`;
|
|
230
|
+
} else {
|
|
231
|
+
css.border = `${n.strokeWeight}px solid ${strokeColor}`;
|
|
232
|
+
if (n.strokeAlign === 'INSIDE') css.boxSizing = 'border-box';
|
|
233
|
+
}
|
|
234
|
+
// Dashed strokes
|
|
235
|
+
if (n.strokeDashes?.length) {
|
|
236
|
+
css.borderStyle = 'dashed';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
142
239
|
// Effects
|
|
143
240
|
const shadows = [];
|
|
144
241
|
for (const e of (n.effects||[]).filter(e=>e.visible!==false)) {
|
|
145
242
|
if (e.type==='DROP_SHADOW'||e.type==='INNER_SHADOW') {
|
|
146
243
|
const ins = e.type==='INNER_SHADOW'?'inset ':'';
|
|
147
244
|
shadows.push(`${ins}${e.offset?.x||0}px ${e.offset?.y||0}px ${e.radius||0}px ${e.spread||0}px ${toCSS(e.color)}`);
|
|
148
|
-
} else if (e.type==='LAYER_BLUR')
|
|
149
|
-
|
|
245
|
+
} else if (e.type==='LAYER_BLUR') {
|
|
246
|
+
css.filter = (css.filter ? css.filter + ' ' : '') + `blur(${e.radius}px)`;
|
|
247
|
+
} else if (e.type==='BACKGROUND_BLUR') css.backdropFilter = `blur(${e.radius}px)`;
|
|
150
248
|
}
|
|
151
249
|
if (shadows.length) css.boxShadow = shadows.join(', ');
|
|
152
250
|
// Text
|
|
@@ -159,26 +257,53 @@ function extractCSS(n) {
|
|
|
159
257
|
if (s.letterSpacing) css.letterSpacing = `${s.letterSpacing}px`;
|
|
160
258
|
const ta = { LEFT:'left', CENTER:'center', RIGHT:'right', JUSTIFIED:'justify' };
|
|
161
259
|
if (s.textAlignHorizontal && ta[s.textAlignHorizontal]) css.textAlign = ta[s.textAlignHorizontal];
|
|
162
|
-
|
|
260
|
+
// textCase → text-transform
|
|
261
|
+
const tc = { UPPER:'uppercase', LOWER:'lowercase', TITLE:'capitalize' };
|
|
262
|
+
if (s.textCase && tc[s.textCase]) css.textTransform = tc[s.textCase];
|
|
263
|
+
// textTruncation → ellipsis
|
|
264
|
+
if (s.textTruncation === 'ENDING') {
|
|
265
|
+
css.overflow = 'hidden'; css.textOverflow = 'ellipsis'; css.whiteSpace = 'nowrap';
|
|
266
|
+
}
|
|
267
|
+
// paragraphSpacing
|
|
268
|
+
if (s.paragraphSpacing > 0) css.marginBottom = `${s.paragraphSpacing}px`;
|
|
269
|
+
const tf = visibleFills.find(f=>f.type==='SOLID');
|
|
163
270
|
if (tf) css.color = toCSS(tf.color);
|
|
164
271
|
}
|
|
165
|
-
|
|
272
|
+
const result = { ...css };
|
|
273
|
+
if (imgRef) result._imageRef = imgRef;
|
|
274
|
+
if (imgScaleMode) result._imageScaleMode = imgScaleMode;
|
|
275
|
+
if (_fills) result._fills = _fills;
|
|
276
|
+
return result;
|
|
166
277
|
}
|
|
167
278
|
|
|
168
279
|
// ─── Tree ───────────────────────────────────────────────────────────
|
|
169
280
|
|
|
170
|
-
function walk(node) {
|
|
281
|
+
function walk(node, parentAbsBBox) {
|
|
171
282
|
const css = extractCSS(node);
|
|
172
283
|
const r = { nodeId: node.id, name: node.name||'', type: node.type, size: null, css: {...css}, children: [] };
|
|
173
284
|
if (node.type==='TEXT' && node.characters) r.text = node.characters;
|
|
174
285
|
if (node.absoluteBoundingBox) r.size = { width: Math.round(node.absoluteBoundingBox.width), height: Math.round(node.absoluteBoundingBox.height) };
|
|
286
|
+
// Unpack internal fields → top-level metadata
|
|
175
287
|
if (css._imageRef) { r.imageRef = css._imageRef; delete r.css._imageRef; }
|
|
176
|
-
if (
|
|
288
|
+
if (css._imageScaleMode) { r.imageScaleMode = css._imageScaleMode; delete r.css._imageScaleMode; }
|
|
289
|
+
if (css._fills) { r.fills = css._fills; delete r.css._fills; }
|
|
290
|
+
// Mask flag (converter skips or applies clip-path)
|
|
291
|
+
if (node.isMask) r.isMask = true;
|
|
292
|
+
// layoutSizing metadata (converter uses with parent context)
|
|
293
|
+
if (node.layoutSizingHorizontal) r.layoutSizingH = node.layoutSizingHorizontal;
|
|
294
|
+
if (node.layoutSizingVertical) r.layoutSizingV = node.layoutSizingVertical;
|
|
295
|
+
// Absolute positioning: parent-relative top/left
|
|
296
|
+
if (node.layoutPositioning === 'ABSOLUTE' && node.absoluteBoundingBox && parentAbsBBox) {
|
|
297
|
+
r.css.top = `${Math.round(node.absoluteBoundingBox.y - parentAbsBBox.y)}px`;
|
|
298
|
+
r.css.left = `${Math.round(node.absoluteBoundingBox.x - parentAbsBBox.x)}px`;
|
|
299
|
+
}
|
|
300
|
+
if (node.children?.length) r.children = node.children.map(c => walk(c, node.absoluteBoundingBox));
|
|
177
301
|
return r;
|
|
178
302
|
}
|
|
179
303
|
|
|
180
304
|
function collectRefs(node, set = new Set()) {
|
|
181
305
|
if (node.imageRef) set.add(node.imageRef);
|
|
306
|
+
if (node.fills) { for (const f of node.fills) { if (f.imageRef) set.add(f.imageRef); } }
|
|
182
307
|
(node.children||[]).forEach(c => collectRefs(c, set));
|
|
183
308
|
return set;
|
|
184
309
|
}
|