@agent-native/core 0.37.1 → 0.37.3
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/dist/brand-kit/fig/decode.d.ts +33 -0
- package/dist/brand-kit/fig/decode.d.ts.map +1 -0
- package/dist/brand-kit/fig/decode.js +358 -0
- package/dist/brand-kit/fig/decode.js.map +1 -0
- package/dist/brand-kit/fig/extract-design-system.d.ts +44 -0
- package/dist/brand-kit/fig/extract-design-system.d.ts.map +1 -0
- package/dist/brand-kit/fig/extract-design-system.js +752 -0
- package/dist/brand-kit/fig/extract-design-system.js.map +1 -0
- package/dist/brand-kit/fig/fig-to-html.d.ts +246 -0
- package/dist/brand-kit/fig/fig-to-html.d.ts.map +1 -0
- package/dist/brand-kit/fig/fig-to-html.js +1506 -0
- package/dist/brand-kit/fig/fig-to-html.js.map +1 -0
- package/dist/brand-kit/fig/index.d.ts +30 -0
- package/dist/brand-kit/fig/index.d.ts.map +1 -0
- package/dist/brand-kit/fig/index.js +43 -0
- package/dist/brand-kit/fig/index.js.map +1 -0
- package/dist/cli/skills.d.ts +4 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +841 -378
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +6 -104
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/context-xray/ContextMeter.js +1 -1
- package/dist/client/context-xray/ContextMeter.js.map +1 -1
- package/dist/client/context-xray/ContextSegmentRow.d.ts.map +1 -1
- package/dist/client/context-xray/ContextSegmentRow.js +4 -4
- package/dist/client/context-xray/ContextSegmentRow.js.map +1 -1
- package/dist/client/context-xray/ContextTreemap.d.ts.map +1 -1
- package/dist/client/context-xray/ContextTreemap.js +2 -2
- package/dist/client/context-xray/ContextTreemap.js.map +1 -1
- package/dist/client/context-xray/ContextXRayPanel.d.ts.map +1 -1
- package/dist/client/context-xray/ContextXRayPanel.js +19 -18
- package/dist/client/context-xray/ContextXRayPanel.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +4 -0
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +6 -4
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a decoded Figma document (the kiwi `Message` tree produced by
|
|
3
|
+
* `decodeFig`) and distil a rich brand profile from it: color roles + palette,
|
|
4
|
+
* a full typographic system (families, weights, scale, letter-spacing, label
|
|
5
|
+
* case), spacing rhythm, corner character, an elevation ramp, and — crucially —
|
|
6
|
+
* the SIGNATURE GRADIENTS and a synthesized brand-character brief.
|
|
7
|
+
*
|
|
8
|
+
* The goal is on-brand generation: not just "use the right fonts and colors",
|
|
9
|
+
* but capture what is distinctive about the brand (its gradient, density,
|
|
10
|
+
* corner language, contrast, type personality) so generated designs feel
|
|
11
|
+
* unmistakably on-brand. The brief is emitted as `customInstructions` (the
|
|
12
|
+
* free-form guidance the generator follows) and the gradients/elevation as
|
|
13
|
+
* `customCSS` tokens.
|
|
14
|
+
*
|
|
15
|
+
* Heuristic by necessity: Figma documents carry no canonical "design system",
|
|
16
|
+
* so we cluster/weight observed values and assign roles by saturation,
|
|
17
|
+
* lightness, contrast, and area. The result is reviewable before saving.
|
|
18
|
+
*/
|
|
19
|
+
import { guidKey } from "./fig-to-html.js";
|
|
20
|
+
// --- color helpers --------------------------------------------------------
|
|
21
|
+
function clamp255(n) {
|
|
22
|
+
return Math.max(0, Math.min(255, Math.round(n)));
|
|
23
|
+
}
|
|
24
|
+
/** Convert a Figma 0-1 RGBA color (folding paint opacity into the channels
|
|
25
|
+
* over white) to a `#rrggbb` hex string. Returns null on invalid input. */
|
|
26
|
+
function colorToHex(c, alphaMul = 1) {
|
|
27
|
+
if (!c)
|
|
28
|
+
return null;
|
|
29
|
+
if (![c.r, c.g, c.b].every((v) => typeof v === "number" && isFinite(v))) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const a = (typeof c.a === "number" ? c.a : 1) * alphaMul;
|
|
33
|
+
const composite = (channel) => channel * a + 1 * (1 - a);
|
|
34
|
+
const r = clamp255(composite(c.r) * 255);
|
|
35
|
+
const g = clamp255(composite(c.g) * 255);
|
|
36
|
+
const b = clamp255(composite(c.b) * 255);
|
|
37
|
+
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
38
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
39
|
+
}
|
|
40
|
+
/** Raw rgba() string for a Figma 0-1 color, preserving alpha (for gradients).
|
|
41
|
+
* `alphaMul` folds in a paint-level opacity that scales every stop's alpha. */
|
|
42
|
+
function colorToRgba(c, alphaMul = 1) {
|
|
43
|
+
if (!c)
|
|
44
|
+
return null;
|
|
45
|
+
if (![c.r, c.g, c.b].every((v) => typeof v === "number" && isFinite(v))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const a = (typeof c.a === "number" ? c.a : 1) * alphaMul;
|
|
49
|
+
return `rgba(${clamp255(c.r * 255)}, ${clamp255(c.g * 255)}, ${clamp255(c.b * 255)}, ${Number(a.toFixed(3))})`;
|
|
50
|
+
}
|
|
51
|
+
function hexToRgb(hex) {
|
|
52
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
53
|
+
if (!m)
|
|
54
|
+
return null;
|
|
55
|
+
const int = parseInt(m[1], 16);
|
|
56
|
+
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
|
|
57
|
+
}
|
|
58
|
+
/** Relative luminance (0 dark .. 1 light) per WCAG. */
|
|
59
|
+
function luminance(hex) {
|
|
60
|
+
const rgb = hexToRgb(hex);
|
|
61
|
+
if (!rgb)
|
|
62
|
+
return 0;
|
|
63
|
+
const lin = (c) => {
|
|
64
|
+
const s = c / 255;
|
|
65
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
66
|
+
};
|
|
67
|
+
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
|
|
68
|
+
}
|
|
69
|
+
/** HSL saturation (0..1) of a hex color. */
|
|
70
|
+
function saturation(hex) {
|
|
71
|
+
const rgb = hexToRgb(hex);
|
|
72
|
+
if (!rgb)
|
|
73
|
+
return 0;
|
|
74
|
+
const r = rgb.r / 255;
|
|
75
|
+
const g = rgb.g / 255;
|
|
76
|
+
const b = rgb.b / 255;
|
|
77
|
+
const max = Math.max(r, g, b);
|
|
78
|
+
const min = Math.min(r, g, b);
|
|
79
|
+
if (max === min)
|
|
80
|
+
return 0;
|
|
81
|
+
const l = (max + min) / 2;
|
|
82
|
+
const d = max - min;
|
|
83
|
+
return l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
84
|
+
}
|
|
85
|
+
/** Hue in degrees (0..360) of a hex color. */
|
|
86
|
+
function hue(hex) {
|
|
87
|
+
const rgb = hexToRgb(hex);
|
|
88
|
+
if (!rgb)
|
|
89
|
+
return 0;
|
|
90
|
+
const r = rgb.r / 255;
|
|
91
|
+
const g = rgb.g / 255;
|
|
92
|
+
const b = rgb.b / 255;
|
|
93
|
+
const max = Math.max(r, g, b);
|
|
94
|
+
const min = Math.min(r, g, b);
|
|
95
|
+
const d = max - min;
|
|
96
|
+
if (d === 0)
|
|
97
|
+
return 0;
|
|
98
|
+
let h;
|
|
99
|
+
if (max === r)
|
|
100
|
+
h = ((g - b) / d) % 6;
|
|
101
|
+
else if (max === g)
|
|
102
|
+
h = (b - r) / d + 2;
|
|
103
|
+
else
|
|
104
|
+
h = (r - g) / d + 4;
|
|
105
|
+
h *= 60;
|
|
106
|
+
return h < 0 ? h + 360 : h;
|
|
107
|
+
}
|
|
108
|
+
/** Human-readable hue name for a saturated color, for the brand brief. */
|
|
109
|
+
function hueName(hex) {
|
|
110
|
+
if (saturation(hex) < 0.12) {
|
|
111
|
+
const l = luminance(hex);
|
|
112
|
+
if (l > 0.8)
|
|
113
|
+
return "near-white";
|
|
114
|
+
if (l < 0.08)
|
|
115
|
+
return "near-black";
|
|
116
|
+
return "neutral grey";
|
|
117
|
+
}
|
|
118
|
+
const h = hue(hex);
|
|
119
|
+
if (h < 15 || h >= 345)
|
|
120
|
+
return "red";
|
|
121
|
+
if (h < 45)
|
|
122
|
+
return "orange";
|
|
123
|
+
if (h < 70)
|
|
124
|
+
return "amber/yellow";
|
|
125
|
+
if (h < 160)
|
|
126
|
+
return "green";
|
|
127
|
+
if (h < 200)
|
|
128
|
+
return "teal/cyan";
|
|
129
|
+
if (h < 250)
|
|
130
|
+
return "blue";
|
|
131
|
+
if (h < 290)
|
|
132
|
+
return "indigo/violet";
|
|
133
|
+
if (h < 330)
|
|
134
|
+
return "magenta/pink";
|
|
135
|
+
return "rose";
|
|
136
|
+
}
|
|
137
|
+
function contrastRatio(a, b) {
|
|
138
|
+
const la = luminance(a);
|
|
139
|
+
const lb = luminance(b);
|
|
140
|
+
const hi = Math.max(la, lb);
|
|
141
|
+
const lo = Math.min(la, lb);
|
|
142
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
143
|
+
}
|
|
144
|
+
function fontWeightFromStyle(style) {
|
|
145
|
+
if (!style)
|
|
146
|
+
return 400;
|
|
147
|
+
const s = style.toLowerCase();
|
|
148
|
+
if (s.includes("thin"))
|
|
149
|
+
return 100;
|
|
150
|
+
if (s.includes("extralight") || s.includes("ultralight"))
|
|
151
|
+
return 200;
|
|
152
|
+
if (s.includes("light"))
|
|
153
|
+
return 300;
|
|
154
|
+
if (s.includes("medium"))
|
|
155
|
+
return 500;
|
|
156
|
+
if (s.includes("semibold") || s.includes("demibold"))
|
|
157
|
+
return 600;
|
|
158
|
+
if (s.includes("extrabold") || s.includes("ultrabold"))
|
|
159
|
+
return 800;
|
|
160
|
+
if (s.includes("black") || s.includes("heavy"))
|
|
161
|
+
return 900;
|
|
162
|
+
if (s.includes("bold"))
|
|
163
|
+
return 700;
|
|
164
|
+
return 400;
|
|
165
|
+
}
|
|
166
|
+
function weightWord(w) {
|
|
167
|
+
if (w <= 200)
|
|
168
|
+
return "ultra-light";
|
|
169
|
+
if (w <= 300)
|
|
170
|
+
return "light";
|
|
171
|
+
if (w === 400)
|
|
172
|
+
return "regular";
|
|
173
|
+
if (w === 500)
|
|
174
|
+
return "medium";
|
|
175
|
+
if (w === 600)
|
|
176
|
+
return "semibold";
|
|
177
|
+
if (w === 700)
|
|
178
|
+
return "bold";
|
|
179
|
+
return "heavy/black";
|
|
180
|
+
}
|
|
181
|
+
function nodeArea(node) {
|
|
182
|
+
const x = node.size?.x ?? 0;
|
|
183
|
+
const y = node.size?.y ?? 0;
|
|
184
|
+
if (!isFinite(x) || !isFinite(y) || x <= 0 || y <= 0)
|
|
185
|
+
return 0;
|
|
186
|
+
return x * y;
|
|
187
|
+
}
|
|
188
|
+
function gcd(a, b) {
|
|
189
|
+
a = Math.abs(Math.round(a));
|
|
190
|
+
b = Math.abs(Math.round(b));
|
|
191
|
+
while (b) {
|
|
192
|
+
[a, b] = [b, a % b];
|
|
193
|
+
}
|
|
194
|
+
return a;
|
|
195
|
+
}
|
|
196
|
+
function inferSpacingStep(values) {
|
|
197
|
+
const positives = values
|
|
198
|
+
.map((v) => Math.round(v))
|
|
199
|
+
.filter((v) => v > 0 && v <= 256);
|
|
200
|
+
if (positives.length === 0)
|
|
201
|
+
return 8;
|
|
202
|
+
const divisibleBy = (d) => positives.filter((v) => v % d === 0).length / positives.length;
|
|
203
|
+
if (divisibleBy(8) >= 0.6)
|
|
204
|
+
return 8;
|
|
205
|
+
if (divisibleBy(4) >= 0.6)
|
|
206
|
+
return 4;
|
|
207
|
+
let g = positives[0];
|
|
208
|
+
for (const v of positives.slice(1))
|
|
209
|
+
g = gcd(g, v);
|
|
210
|
+
return g >= 2 ? g : 4;
|
|
211
|
+
}
|
|
212
|
+
/** Build a CSS gradient string from a Figma gradient Paint. Angle is
|
|
213
|
+
* approximated (the exact handle vector isn't always recoverable); the stop
|
|
214
|
+
* colors — the brand-signature part — are captured faithfully. */
|
|
215
|
+
function gradientToCss(paint) {
|
|
216
|
+
const stops = (paint.stops ?? [])
|
|
217
|
+
.filter((s) => s && s.color)
|
|
218
|
+
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
219
|
+
if (stops.length < 2)
|
|
220
|
+
return null;
|
|
221
|
+
const parts = [];
|
|
222
|
+
const keyParts = [];
|
|
223
|
+
for (const s of stops) {
|
|
224
|
+
// Fold the paint-level opacity into every stop's alpha.
|
|
225
|
+
const rgba = colorToRgba(s.color, paint.opacity ?? 1);
|
|
226
|
+
if (!rgba)
|
|
227
|
+
return null;
|
|
228
|
+
const pct = clampPct(s.position);
|
|
229
|
+
parts.push(`${rgba} ${pct}%`);
|
|
230
|
+
// Key on color AND position so two gradients with the same colors but
|
|
231
|
+
// different stop placement aren't treated as duplicates.
|
|
232
|
+
keyParts.push(`${colorToHex(s.color) ?? rgba}@${pct}`);
|
|
233
|
+
}
|
|
234
|
+
const body = parts.join(", ");
|
|
235
|
+
let css;
|
|
236
|
+
switch (paint.type) {
|
|
237
|
+
case "GRADIENT_RADIAL":
|
|
238
|
+
css = `radial-gradient(circle at 30% 30%, ${body})`;
|
|
239
|
+
break;
|
|
240
|
+
case "GRADIENT_ANGULAR":
|
|
241
|
+
css = `conic-gradient(from 180deg, ${body})`;
|
|
242
|
+
break;
|
|
243
|
+
case "GRADIENT_DIAMOND":
|
|
244
|
+
css = `radial-gradient(${body})`;
|
|
245
|
+
break;
|
|
246
|
+
default:
|
|
247
|
+
css = `linear-gradient(135deg, ${body})`;
|
|
248
|
+
}
|
|
249
|
+
return { css, key: `${paint.type}:${keyParts.join("|")}` };
|
|
250
|
+
}
|
|
251
|
+
function clampPct(p) {
|
|
252
|
+
const v = typeof p === "number" ? p * 100 : 0;
|
|
253
|
+
return Math.max(0, Math.min(100, Math.round(v)));
|
|
254
|
+
}
|
|
255
|
+
function indexStyleNames(nodes) {
|
|
256
|
+
const out = new Map();
|
|
257
|
+
for (const n of nodes) {
|
|
258
|
+
if ((n.styleType || n.type === "STYLE") && n.name) {
|
|
259
|
+
out.set(guidKey(n.guid), n.name.trim());
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
function resolveFillStyleName(node, styleNames) {
|
|
265
|
+
const g = node.styleIdForFill?.guid;
|
|
266
|
+
if (!g)
|
|
267
|
+
return undefined;
|
|
268
|
+
return styleNames.get(guidKey(g));
|
|
269
|
+
}
|
|
270
|
+
function resolveTextStyleName(node, styleNames) {
|
|
271
|
+
const g = node.styleIdForText?.guid;
|
|
272
|
+
if (!g)
|
|
273
|
+
return undefined;
|
|
274
|
+
return styleNames.get(guidKey(g));
|
|
275
|
+
}
|
|
276
|
+
export function extractDesignSystemFromFig(document) {
|
|
277
|
+
const doc = document;
|
|
278
|
+
const nodes = doc?.nodeChanges ?? [];
|
|
279
|
+
if (nodes.length === 0)
|
|
280
|
+
return {};
|
|
281
|
+
const styleNames = indexStyleNames(nodes);
|
|
282
|
+
const colorStats = new Map();
|
|
283
|
+
const namedColors = {};
|
|
284
|
+
const textStats = new Map();
|
|
285
|
+
const radii = [];
|
|
286
|
+
const spacingValues = [];
|
|
287
|
+
const shadows = [];
|
|
288
|
+
const gradientStats = new Map();
|
|
289
|
+
// Gradient stop colors are collected separately and only folded into the
|
|
290
|
+
// palette when SOLID fills are too sparse to derive roles — otherwise a
|
|
291
|
+
// bright gradient stop can hijack the accent over the real solid brand color.
|
|
292
|
+
const gradientStopColors = [];
|
|
293
|
+
// Heuristic label-case signal: small text whose content is all-caps.
|
|
294
|
+
let upperLabelHits = 0;
|
|
295
|
+
let smallTextSamples = 0;
|
|
296
|
+
const addColor = (hex, area, name) => {
|
|
297
|
+
if (!hex)
|
|
298
|
+
return;
|
|
299
|
+
const existing = colorStats.get(hex);
|
|
300
|
+
if (existing) {
|
|
301
|
+
existing.count += 1;
|
|
302
|
+
existing.area += area;
|
|
303
|
+
if (!existing.name && name)
|
|
304
|
+
existing.name = name;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
colorStats.set(hex, { hex, count: 1, area, name });
|
|
308
|
+
}
|
|
309
|
+
if (name && !namedColors[name])
|
|
310
|
+
namedColors[name] = hex;
|
|
311
|
+
};
|
|
312
|
+
for (const node of nodes) {
|
|
313
|
+
if (node.visible === false)
|
|
314
|
+
continue;
|
|
315
|
+
const area = nodeArea(node);
|
|
316
|
+
const fillStyleName = resolveFillStyleName(node, styleNames);
|
|
317
|
+
for (const paint of (node.fillPaints ?? [])) {
|
|
318
|
+
if (paint.visible === false)
|
|
319
|
+
continue;
|
|
320
|
+
if (paint.type === "SOLID") {
|
|
321
|
+
addColor(colorToHex(paint.color, paint.opacity ?? 1), area, fillStyleName);
|
|
322
|
+
}
|
|
323
|
+
else if (paint.type?.startsWith("GRADIENT_")) {
|
|
324
|
+
const grad = gradientToCss(paint);
|
|
325
|
+
if (grad) {
|
|
326
|
+
const existing = gradientStats.get(grad.key);
|
|
327
|
+
if (existing) {
|
|
328
|
+
existing.area += area;
|
|
329
|
+
existing.count += 1;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
gradientStats.set(grad.key, {
|
|
333
|
+
css: grad.css,
|
|
334
|
+
key: grad.key,
|
|
335
|
+
area,
|
|
336
|
+
count: 1,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// Stash gradient stop colors; only used for roles if SOLID fills
|
|
340
|
+
// turn out to be too sparse (handled after the walk).
|
|
341
|
+
for (const s of paint.stops ?? []) {
|
|
342
|
+
const hex = colorToHex(s.color);
|
|
343
|
+
if (hex)
|
|
344
|
+
gradientStopColors.push({ hex, area: area * 0.2 });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const paint of (node.strokePaints ?? [])) {
|
|
350
|
+
if (paint.visible === false)
|
|
351
|
+
continue;
|
|
352
|
+
if (paint.type !== "SOLID")
|
|
353
|
+
continue;
|
|
354
|
+
addColor(colorToHex(paint.color, paint.opacity ?? 1), 0);
|
|
355
|
+
}
|
|
356
|
+
// Typography from TEXT nodes.
|
|
357
|
+
if (node.type === "TEXT" && node.fontName?.family) {
|
|
358
|
+
const family = node.fontName.family;
|
|
359
|
+
const weight = fontWeightFromStyle(node.fontName.style);
|
|
360
|
+
const size = typeof node.fontSize === "number" ? Math.round(node.fontSize) : 0;
|
|
361
|
+
const textStyleName = resolveTextStyleName(node, styleNames);
|
|
362
|
+
const key = textStyleName ? `style:${textStyleName}|${family}` : family;
|
|
363
|
+
let stat = textStats.get(key);
|
|
364
|
+
if (!stat) {
|
|
365
|
+
stat = {
|
|
366
|
+
family,
|
|
367
|
+
weights: new Map(),
|
|
368
|
+
sizes: new Map(),
|
|
369
|
+
lineHeights: [],
|
|
370
|
+
letterSpacings: [],
|
|
371
|
+
count: 0,
|
|
372
|
+
area: 0,
|
|
373
|
+
};
|
|
374
|
+
textStats.set(key, stat);
|
|
375
|
+
}
|
|
376
|
+
stat.count += 1;
|
|
377
|
+
stat.area += area;
|
|
378
|
+
stat.weights.set(weight, (stat.weights.get(weight) ?? 0) + 1);
|
|
379
|
+
if (size > 0)
|
|
380
|
+
stat.sizes.set(size, (stat.sizes.get(size) ?? 0) + 1);
|
|
381
|
+
if (node.lineHeight && typeof node.lineHeight.value === "number") {
|
|
382
|
+
if (node.lineHeight.units === "PIXELS") {
|
|
383
|
+
stat.lineHeights.push(node.lineHeight.value);
|
|
384
|
+
}
|
|
385
|
+
else if (node.lineHeight.units === "PERCENT" && size > 0) {
|
|
386
|
+
stat.lineHeights.push((node.lineHeight.value / 100) * size);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (node.letterSpacing &&
|
|
390
|
+
typeof node.letterSpacing.value === "number" &&
|
|
391
|
+
size > 0) {
|
|
392
|
+
const ls = node.letterSpacing.units === "PERCENT"
|
|
393
|
+
? (node.letterSpacing.value / 100) * size
|
|
394
|
+
: node.letterSpacing.value;
|
|
395
|
+
stat.letterSpacings.push(ls / size); // em-relative
|
|
396
|
+
}
|
|
397
|
+
// Label-case heuristic: short, small text rendered in all caps.
|
|
398
|
+
const chars = node.textData?.characters;
|
|
399
|
+
if (size > 0 && size <= 18 && chars && chars.trim().length > 0) {
|
|
400
|
+
smallTextSamples += 1;
|
|
401
|
+
const letters = chars.replace(/[^a-z]/gi, "");
|
|
402
|
+
if (letters.length >= 2 && letters === letters.toUpperCase()) {
|
|
403
|
+
upperLabelHits += 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Corner radii.
|
|
408
|
+
const corner = node.cornerRadius ??
|
|
409
|
+
node.rectangleTopLeftCornerRadius ??
|
|
410
|
+
node.rectangleTopRightCornerRadius;
|
|
411
|
+
if (typeof corner === "number" && corner > 0 && corner <= 200) {
|
|
412
|
+
radii.push(Math.round(corner));
|
|
413
|
+
}
|
|
414
|
+
// Spacing: auto-layout gaps and padding.
|
|
415
|
+
if (node.stackMode && node.stackMode !== "NONE") {
|
|
416
|
+
if (typeof node.stackSpacing === "number")
|
|
417
|
+
spacingValues.push(node.stackSpacing);
|
|
418
|
+
for (const pad of [
|
|
419
|
+
node.stackPaddingLeft,
|
|
420
|
+
node.stackPaddingRight,
|
|
421
|
+
node.stackPaddingTop,
|
|
422
|
+
node.stackPaddingBottom,
|
|
423
|
+
node.stackHorizontalPadding,
|
|
424
|
+
node.stackVerticalPadding,
|
|
425
|
+
]) {
|
|
426
|
+
if (typeof pad === "number" && pad > 0)
|
|
427
|
+
spacingValues.push(pad);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Effects -> shadows.
|
|
431
|
+
for (const effect of (node.effects ?? [])) {
|
|
432
|
+
if (effect.visible === false)
|
|
433
|
+
continue;
|
|
434
|
+
if (effect.type !== "DROP_SHADOW" && effect.type !== "INNER_SHADOW")
|
|
435
|
+
continue;
|
|
436
|
+
const c = effect.color;
|
|
437
|
+
const a = c && typeof c.a === "number" ? c.a : 0.25;
|
|
438
|
+
const rgb = c
|
|
439
|
+
? `rgba(${clamp255(c.r * 255)}, ${clamp255(c.g * 255)}, ${clamp255(c.b * 255)}, ${Number(a.toFixed(3))})`
|
|
440
|
+
: "rgba(0, 0, 0, 0.25)";
|
|
441
|
+
const inset = effect.type === "INNER_SHADOW" ? "inset " : "";
|
|
442
|
+
const ox = Math.round(effect.offset?.x ?? 0);
|
|
443
|
+
const oy = Math.round(effect.offset?.y ?? 0);
|
|
444
|
+
const blur = Math.round(effect.radius ?? 0);
|
|
445
|
+
const spread = Math.round(effect.spread ?? 0);
|
|
446
|
+
// Skip artboard-scale shadows (giant blurs/offsets common on Figma frame
|
|
447
|
+
// backgrounds) — keep only UI-plausible elevation.
|
|
448
|
+
if (blur > 80 ||
|
|
449
|
+
Math.abs(spread) > 64 ||
|
|
450
|
+
Math.abs(ox) > 120 ||
|
|
451
|
+
Math.abs(oy) > 120) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
shadows.push(`${inset}${ox}px ${oy}px ${blur}px ${spread}px ${rgb}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// --- assign color roles -------------------------------------------------
|
|
458
|
+
// Fold gradient stop colors into the palette only when solid fills are too
|
|
459
|
+
// sparse to characterize the brand (e.g. a gradient-only file).
|
|
460
|
+
if (colorStats.size < 4) {
|
|
461
|
+
for (const g of gradientStopColors)
|
|
462
|
+
addColor(g.hex, g.area, g.name);
|
|
463
|
+
}
|
|
464
|
+
const palette = Array.from(colorStats.values()).sort((a, b) => b.count - a.count || b.area - a.area);
|
|
465
|
+
const result = { nodeCount: nodes.length };
|
|
466
|
+
let roles = null;
|
|
467
|
+
if (palette.length > 0) {
|
|
468
|
+
const byArea = [...palette].sort((a, b) => b.area - a.area);
|
|
469
|
+
const background = byArea.find((c) => {
|
|
470
|
+
const l = luminance(c.hex);
|
|
471
|
+
return l > 0.85 || l < 0.08;
|
|
472
|
+
}) ?? byArea[0];
|
|
473
|
+
const accent = palette
|
|
474
|
+
.filter((c) => c.hex !== background.hex)
|
|
475
|
+
.sort((a, b) => saturation(b.hex) - saturation(a.hex))[0] ?? background;
|
|
476
|
+
const text = palette
|
|
477
|
+
.filter((c) => c.hex !== background.hex)
|
|
478
|
+
.sort((a, b) => contrastRatio(b.hex, background.hex) -
|
|
479
|
+
contrastRatio(a.hex, background.hex))[0] ?? { hex: luminance(background.hex) > 0.5 ? "#111111" : "#ffffff" };
|
|
480
|
+
const saturated = palette
|
|
481
|
+
.filter((c) => saturation(c.hex) > 0.15 &&
|
|
482
|
+
c.hex !== background.hex &&
|
|
483
|
+
c.hex !== text.hex)
|
|
484
|
+
.sort((a, b) => b.count - a.count);
|
|
485
|
+
const primary = saturated[0] ?? accent;
|
|
486
|
+
const secondary = saturated[1] ?? saturated[0] ?? primary;
|
|
487
|
+
const surface = byArea.find((c) => c.hex !== background.hex &&
|
|
488
|
+
Math.abs(luminance(c.hex) - luminance(background.hex)) < 0.15) ?? background;
|
|
489
|
+
const textMuted = palette
|
|
490
|
+
.filter((c) => {
|
|
491
|
+
const cr = contrastRatio(c.hex, background.hex);
|
|
492
|
+
return c.hex !== text.hex && cr >= 2 && cr <= 7;
|
|
493
|
+
})
|
|
494
|
+
.sort((a, b) => contrastRatio(b.hex, background.hex) -
|
|
495
|
+
contrastRatio(a.hex, background.hex))[0]?.hex ?? mixHex(text.hex, background.hex, 0.45);
|
|
496
|
+
roles = {
|
|
497
|
+
primary: primary.hex,
|
|
498
|
+
secondary: secondary.hex,
|
|
499
|
+
accent: accent.hex,
|
|
500
|
+
background: background.hex,
|
|
501
|
+
surface: surface.hex,
|
|
502
|
+
text: text.hex,
|
|
503
|
+
textMuted: typeof textMuted === "string" ? textMuted : text.hex,
|
|
504
|
+
};
|
|
505
|
+
result.colors = roles;
|
|
506
|
+
result.palette = palette
|
|
507
|
+
.slice(0, 24)
|
|
508
|
+
.map((c) => ({ hex: c.hex, name: c.name, count: c.count }));
|
|
509
|
+
if (Object.keys(namedColors).length > 0)
|
|
510
|
+
result.namedColors = namedColors;
|
|
511
|
+
}
|
|
512
|
+
// --- typography ---------------------------------------------------------
|
|
513
|
+
let typo = null;
|
|
514
|
+
if (textStats.size > 0) {
|
|
515
|
+
const stats = Array.from(textStats.values());
|
|
516
|
+
const byArea = [...stats].sort((a, b) => b.area - a.area || b.count - a.count);
|
|
517
|
+
const bodyStat = byArea[0];
|
|
518
|
+
const maxSizeOf = (s) => Math.max(0, ...Array.from(s.sizes.keys()));
|
|
519
|
+
const byMaxSize = [...stats].sort((a, b) => maxSizeOf(b) - maxSizeOf(a));
|
|
520
|
+
const headingStat = byMaxSize.find((s) => s.family !== bodyStat.family) ??
|
|
521
|
+
byMaxSize[0] ??
|
|
522
|
+
bodyStat;
|
|
523
|
+
const topWeight = (s) => {
|
|
524
|
+
let best = 400;
|
|
525
|
+
let bestCount = -1;
|
|
526
|
+
for (const [w, c] of s.weights) {
|
|
527
|
+
if (c > bestCount) {
|
|
528
|
+
best = w;
|
|
529
|
+
bestCount = c;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return best;
|
|
533
|
+
};
|
|
534
|
+
// Web-plausible sizes only. Figma marketing boards carry display text at
|
|
535
|
+
// hundreds/thousands of px, which is useless as a web type scale — keep
|
|
536
|
+
// 8..200px and clamp the final scale into a sane web range.
|
|
537
|
+
const webSizes = Array.from(new Set(stats.flatMap((s) => Array.from(s.sizes.keys()))))
|
|
538
|
+
.filter((n) => n >= 8 && n <= 200)
|
|
539
|
+
.sort((a, b) => b - a);
|
|
540
|
+
const clampHead = (n) => Math.min(72, Math.max(18, Math.round(n)));
|
|
541
|
+
const h1 = clampHead(webSizes[0] ?? 48);
|
|
542
|
+
const h2 = clampHead(webSizes.find((n) => n < h1) ?? Math.round(h1 * 0.72));
|
|
543
|
+
const h3 = clampHead(webSizes.find((n) => n < h2) ?? Math.round(h2 * 0.72));
|
|
544
|
+
const avgTracking = headingStat.letterSpacings.length > 0
|
|
545
|
+
? headingStat.letterSpacings.reduce((a, b) => a + b, 0) /
|
|
546
|
+
headingStat.letterSpacings.length
|
|
547
|
+
: 0;
|
|
548
|
+
const rawBody = Array.from(bodyStat.sizes.entries())
|
|
549
|
+
.filter(([s]) => s >= 8 && s <= 28)
|
|
550
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? 16;
|
|
551
|
+
const bodySize = Math.min(20, Math.max(14, Math.round(rawBody)));
|
|
552
|
+
result.typography = {
|
|
553
|
+
headingFont: headingStat.family,
|
|
554
|
+
bodyFont: bodyStat.family,
|
|
555
|
+
headingWeight: String(topWeight(headingStat)),
|
|
556
|
+
bodyWeight: String(topWeight(bodyStat)),
|
|
557
|
+
headingSizes: { h1: `${h1}px`, h2: `${h2}px`, h3: `${h3}px` },
|
|
558
|
+
};
|
|
559
|
+
typo = {
|
|
560
|
+
headingStat,
|
|
561
|
+
bodyStat,
|
|
562
|
+
headingWeight: topWeight(headingStat),
|
|
563
|
+
bodyWeight: topWeight(bodyStat),
|
|
564
|
+
h1,
|
|
565
|
+
body: bodySize,
|
|
566
|
+
headingTracking: avgTracking,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
// --- spacing ------------------------------------------------------------
|
|
570
|
+
let elementGapPx = 0;
|
|
571
|
+
if (spacingValues.length > 0) {
|
|
572
|
+
const step = inferSpacingStep(spacingValues);
|
|
573
|
+
const rounded = spacingValues
|
|
574
|
+
.map((v) => Math.round(v))
|
|
575
|
+
.sort((a, b) => a - b);
|
|
576
|
+
const median = rounded[Math.floor(rounded.length / 2)] ?? step * 2;
|
|
577
|
+
const max = rounded[rounded.length - 1] ?? step * 3;
|
|
578
|
+
const snap = (v) => Math.max(step, Math.round(v / step) * step);
|
|
579
|
+
elementGapPx = snap(median);
|
|
580
|
+
result.spacing = {
|
|
581
|
+
pagePadding: `${snap(Math.min(max, step * 8))}px`,
|
|
582
|
+
elementGap: `${elementGapPx}px`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
// --- borders / corner character -----------------------------------------
|
|
586
|
+
let radiusPx = 0;
|
|
587
|
+
if (radii.length > 0) {
|
|
588
|
+
const counts = new Map();
|
|
589
|
+
for (const r of radii)
|
|
590
|
+
counts.set(r, (counts.get(r) ?? 0) + 1);
|
|
591
|
+
radiusPx = Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0];
|
|
592
|
+
result.borders = { radius: `${radiusPx}px`, accentWidth: "2px" };
|
|
593
|
+
}
|
|
594
|
+
// --- gradients + elevation into customCSS -------------------------------
|
|
595
|
+
// Rank by colorfulness first: a saturated brand gradient should beat the
|
|
596
|
+
// big black/white scrim overlays that dominate by area in marketing files.
|
|
597
|
+
const gradientColorfulness = (key) => {
|
|
598
|
+
const parts = key.split(":")[1]?.split("|") ?? [];
|
|
599
|
+
let s = 0;
|
|
600
|
+
for (const p of parts) {
|
|
601
|
+
// Each part is "<hex>@<pos>" — score on the hex.
|
|
602
|
+
const hex = p.split("@")[0]?.trim() ?? "";
|
|
603
|
+
if (/^#[0-9a-f]{6}$/i.test(hex))
|
|
604
|
+
s += saturation(hex);
|
|
605
|
+
}
|
|
606
|
+
return s;
|
|
607
|
+
};
|
|
608
|
+
const topGradients = Array.from(gradientStats.values())
|
|
609
|
+
.sort((a, b) => gradientColorfulness(b.key) - gradientColorfulness(a.key) ||
|
|
610
|
+
b.area - a.area ||
|
|
611
|
+
b.count - a.count)
|
|
612
|
+
.slice(0, 4);
|
|
613
|
+
if (topGradients.length > 0) {
|
|
614
|
+
result.gradients = topGradients.map((g) => g.css);
|
|
615
|
+
}
|
|
616
|
+
const cssVars = [];
|
|
617
|
+
topGradients.forEach((g, i) => {
|
|
618
|
+
cssVars.push(` --gradient-${i === 0 ? "brand" : i}: ${g.css};`);
|
|
619
|
+
});
|
|
620
|
+
const uniqueShadows = Array.from(new Set(shadows)).slice(0, 6);
|
|
621
|
+
uniqueShadows.forEach((s, i) => {
|
|
622
|
+
cssVars.push(` --shadow-${i + 1}: ${s};`);
|
|
623
|
+
});
|
|
624
|
+
if (cssVars.length > 0) {
|
|
625
|
+
result.customCSS = `:root {\n${cssVars.join("\n")}\n}`;
|
|
626
|
+
}
|
|
627
|
+
// --- defaults -----------------------------------------------------------
|
|
628
|
+
const bgHex = roles?.background;
|
|
629
|
+
const isDark = bgHex ? luminance(bgHex) < 0.4 : false;
|
|
630
|
+
const labelStyle = smallTextSamples >= 3 && upperLabelHits / smallTextSamples > 0.4
|
|
631
|
+
? "uppercase"
|
|
632
|
+
: "none";
|
|
633
|
+
result.defaults = {
|
|
634
|
+
background: isDark ? "dark" : "light",
|
|
635
|
+
labelStyle,
|
|
636
|
+
};
|
|
637
|
+
// --- synthesize the brand brief + imagery hint --------------------------
|
|
638
|
+
const brief = synthesizeBrandBrief({
|
|
639
|
+
roles,
|
|
640
|
+
isDark,
|
|
641
|
+
saturatedCount: palette.filter((c) => saturation(c.hex) > 0.2).length,
|
|
642
|
+
gradients: result.gradients ?? [],
|
|
643
|
+
typo,
|
|
644
|
+
elementGapPx,
|
|
645
|
+
radiusPx,
|
|
646
|
+
hasShadows: uniqueShadows.length > 0,
|
|
647
|
+
labelStyle,
|
|
648
|
+
namedStyleCount: styleNames.size,
|
|
649
|
+
});
|
|
650
|
+
if (brief.instructions)
|
|
651
|
+
result.customInstructions = brief.instructions;
|
|
652
|
+
if (brief.imageryDescription) {
|
|
653
|
+
result.imageStyle = {
|
|
654
|
+
referenceUrls: [],
|
|
655
|
+
styleDescription: brief.imageryDescription,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
result.notes = `Imported from a Figma .fig file — ${nodes.length.toLocaleString()} nodes, ${styleNames.size} named styles${result.gradients ? `, ${result.gradients.length} signature gradient(s)` : ""}. Tokens + brand brief were auto-extracted and are editable.`;
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
// --- brand brief synthesis ------------------------------------------------
|
|
662
|
+
function synthesizeBrandBrief(input) {
|
|
663
|
+
const lines = [];
|
|
664
|
+
const adjectives = [];
|
|
665
|
+
// Theme + color character.
|
|
666
|
+
if (input.roles) {
|
|
667
|
+
const theme = input.isDark ? "dark-first" : "light/airy";
|
|
668
|
+
adjectives.push(input.isDark ? "dark" : "bright");
|
|
669
|
+
const colorChar = input.saturatedCount <= 1
|
|
670
|
+
? "restrained and largely monochromatic"
|
|
671
|
+
: input.saturatedCount <= 3
|
|
672
|
+
? "focused — a tight palette with one or two signature hues"
|
|
673
|
+
: "expressive and multi-color";
|
|
674
|
+
adjectives.push(input.saturatedCount <= 1 ? "minimal" : "vivid");
|
|
675
|
+
lines.push(`Palette: ${theme}, ${colorChar}. The signature accent is ${hueName(input.roles.accent)} (${input.roles.accent}); background ${input.roles.background}, text ${input.roles.text}. Use the exact token colors above for every surface — never hardcode off-brand colors.`);
|
|
676
|
+
}
|
|
677
|
+
// Signature gradient — usually the single most recognizable brand element.
|
|
678
|
+
if (input.gradients.length > 0) {
|
|
679
|
+
adjectives.push("gradient-forward");
|
|
680
|
+
lines.push(`Signature gradient(s) are core to this brand — lead with them on hero backgrounds, primary buttons, and accents (available as \`--gradient-brand\` in customCSS): ${input.gradients
|
|
681
|
+
.slice(0, 2)
|
|
682
|
+
.join(" • ")}.`);
|
|
683
|
+
}
|
|
684
|
+
// Typography personality.
|
|
685
|
+
if (input.typo) {
|
|
686
|
+
const hw = weightWord(input.typo.headingWeight);
|
|
687
|
+
const ratio = input.typo.body > 0 ? input.typo.h1 / input.typo.body : 2;
|
|
688
|
+
const scaleChar = ratio >= 3
|
|
689
|
+
? "a dramatic, oversized display scale"
|
|
690
|
+
: ratio >= 2
|
|
691
|
+
? "a confident, clear type scale"
|
|
692
|
+
: "a restrained, editorial scale";
|
|
693
|
+
adjectives.push(input.typo.headingWeight >= 700 ? "bold" : "refined");
|
|
694
|
+
const tracking = input.typo.headingTracking <= -0.02
|
|
695
|
+
? " with tight, negative letter-spacing on headings"
|
|
696
|
+
: input.typo.headingTracking >= 0.04
|
|
697
|
+
? " with wide, airy letter-spacing on headings"
|
|
698
|
+
: "";
|
|
699
|
+
lines.push(`Typography: headings in ${input.typo.headingStat.family} (${hw}, ~${input.typo.h1}px at the top of ${scaleChar})${tracking}; body in ${input.typo.bodyStat.family} (~${input.typo.body}px). Load these exact families from Google Fonts and honor the weight contrast.`);
|
|
700
|
+
}
|
|
701
|
+
// Spacing rhythm.
|
|
702
|
+
if (input.elementGapPx > 0) {
|
|
703
|
+
const dense = input.elementGapPx <= 12;
|
|
704
|
+
adjectives.push(dense ? "compact" : "spacious");
|
|
705
|
+
lines.push(`Density: ${dense ? "tight and information-dense" : "generous, with confident whitespace"} — typical element gap ~${input.elementGapPx}px. Keep that rhythm consistent.`);
|
|
706
|
+
}
|
|
707
|
+
// Corner language.
|
|
708
|
+
if (input.radiusPx >= 0 && (input.radiusPx > 0 || input.roles)) {
|
|
709
|
+
const corner = input.radiusPx <= 4
|
|
710
|
+
? "sharp, squared corners (precise / technical)"
|
|
711
|
+
: input.radiusPx <= 12
|
|
712
|
+
? "softly rounded corners"
|
|
713
|
+
: input.radiusPx <= 22
|
|
714
|
+
? "noticeably rounded, friendly corners"
|
|
715
|
+
: "very round, pill-like corners";
|
|
716
|
+
adjectives.push(input.radiusPx <= 4 ? "precise" : "approachable");
|
|
717
|
+
lines.push(`Corner language: ${corner} (~${input.radiusPx}px). Apply it consistently to cards, buttons, and inputs.`);
|
|
718
|
+
}
|
|
719
|
+
// Elevation.
|
|
720
|
+
if (input.hasShadows) {
|
|
721
|
+
adjectives.push("layered");
|
|
722
|
+
lines.push(`Depth: uses layered, soft shadows (see \`--shadow-*\` tokens) — favor subtle elevation over hard borders.`);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
lines.push(`Depth: largely flat — prefer borders, tonal surfaces, and color over drop shadows.`);
|
|
726
|
+
}
|
|
727
|
+
if (input.labelStyle === "uppercase") {
|
|
728
|
+
lines.push(`Labels/eyebrows are set in UPPERCASE with letter-spacing — use that for small section labels.`);
|
|
729
|
+
}
|
|
730
|
+
const character = Array.from(new Set(adjectives)).slice(0, 5).join(", ");
|
|
731
|
+
const header = character
|
|
732
|
+
? `This brand reads as: ${character}. Generate every design so it feels unmistakably on-brand — a stranger should recognize it as the same brand as the source.`
|
|
733
|
+
: `Generate designs that match the extracted tokens precisely so output stays on-brand.`;
|
|
734
|
+
const instructions = [header, "", ...lines].join("\n");
|
|
735
|
+
const imageryDescription = input.roles
|
|
736
|
+
? `${input.isDark ? "Dark, high-contrast" : "Bright, clean"} brand imagery; ${input.gradients.length > 0
|
|
737
|
+
? "lean on the signature gradient and bold color"
|
|
738
|
+
: "lean on confident color blocking and whitespace"}. Avoid generic stock-photo clichés and off-brand color.`
|
|
739
|
+
: "";
|
|
740
|
+
return { instructions, imageryDescription };
|
|
741
|
+
}
|
|
742
|
+
/** Linear blend of two hex colors. `t` = weight of `a` (0..1). */
|
|
743
|
+
function mixHex(a, b, t) {
|
|
744
|
+
const ca = hexToRgb(a);
|
|
745
|
+
const cb = hexToRgb(b);
|
|
746
|
+
if (!ca || !cb)
|
|
747
|
+
return a;
|
|
748
|
+
const mix = (x, y) => clamp255(x * t + y * (1 - t));
|
|
749
|
+
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
750
|
+
return `#${hex(mix(ca.r, cb.r))}${hex(mix(ca.g, cb.g))}${hex(mix(ca.b, cb.b))}`;
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=extract-design-system.js.map
|