Коли токени розлетілися
Пара ітерацій з клієнтом, кілька правок «на швидку» - і в макеті сотні хексів замість змінних. Девелопер бачить #2E6BFF і пише свій синій. Дизайн-система ніби є, а в коді її немає.
Що зробимо: Code Runner покаже, скільки шарів живе на хексах. Один скрипт розбере всі кольори, сам збудує з них токени і прив'яже кожен шар. Далі - розбір файлу, кейси, що ще вміє Code Runner, і VooDoo Maker, який збирає болванку дизайн-системи з нуля.
Встанови два плагіни
Обидва безкоштовні і працюють на безкоштовній Figma.
- UX Hero: Code Runner - запускає JavaScript прямо у файлі. Скрипти пише Claude. Figma Community ↗
- UX Hero: VooDoo Maker - дизайн-система з одного кольору: змінні в 3 шарах, світла і темна тема, текстові стилі, компоненти. Figma Community ↗
У Community тисни Open in… → свій файл. Далі плагін є в правому кліку: Plugins → UX Hero: …
Порахуй, скільки шарів на хексах
- Правий клік → Plugins → UX Hero: Code Runner.
- У рядку Snippets натисни Audit colors, потім Run.
- Плагін виділить усі шари з хексами, а в консолі покаже таблицю: шар і його кольори. Внизу - скільки таких шарів. Запам'ятай цифру: в кінці порівняємо.
Хекси → токени одним скриптом
Скрипт збирає всі кольори без змінних, склеює майже однакові (#2E6BFF і #2F6CFE) і будує систему у 2 шари:
- Brand · Primitives - сама палітра:
blue/400,neutral/950,white. Сховані з пікера кольорів, щоб у макеті їх не брали напряму. - Brand · Theme - ролі, що посилаються на палітру:
text/primary,bg/canvas,border/default,action/primary. Роль визначає тип шару: текст, рамка, заливка.
Потім прив'язує кожен шар до його ролі. Кольори, які вже є змінними у файлі, не дублює.
- Скопіюй скрипт, встав у Code Runner, Run. Хочеш спершу подивитись план - постав зверху
APPLY = false: скрипт лише покаже таблицю. - Відкрий Variables: дві колекції. Перейменуй будь-що - шари лишаються прив'язаними.
- Ще раз Audit colors: «No hardcoded colors». Шари всередині інстансів виправляються в головному компоненті.
// UX Hero · Code Runner: hardcoded hex -> design tokens
// Reads every raw color on the page, groups near-identical ones, and builds a 2-layer system:
// Brand · Primitives - the palette itself: blue/500, neutral/100, white ... (hidden from pickers)
// Brand · Theme - roles that point to primitives: text/primary, bg/canvas, border/default, action/primary
// Then binds every layer to its role token. Text gets text/..., strokes get border/..., fills get bg/... or action/...
// Colors that already exist as a variable in the file are reused, not duplicated.
// Scope: selection, or the whole page if nothing is selected. Layers inside instances are skipped
// (fix them in the main component). Undo: one Cmd+Z. Safe to run again.
const APPLY = true; // false = only show the plan in the console, change nothing
const MERGE = 6; // 0-255: colors this close become one token (#2E6BFF and #2F6CFE)
const roots = figma.currentPage.selection.length ? [...figma.currentPage.selection] : [figma.currentPage];
const toHex = c => '#' + [c.r, c.g, c.b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('').toUpperCase();
const dist = (a, b) => Math.max(Math.abs(a.r - b.r), Math.abs(a.g - b.g), Math.abs(a.b - b.b)) * 255;
function hsl(c) {
const max = Math.max(c.r, c.g, c.b), min = Math.min(c.r, c.g, c.b), l = (max + min) / 2, d = max - min;
if (!d) return { h: 0, s: 0, l };
const s = d / (1 - Math.abs(2 * l - 1));
let h = max === c.r ? ((c.g - c.b) / d) % 6 : max === c.g ? (c.b - c.r) / d + 2 : (c.r - c.g) / d + 4;
return { h: (h * 60 + 360) % 360, s, l };
}
function family(c) {
const { h, s, l } = hsl(c);
if (l > 0.985) return 'white';
if (l < 0.03) return 'black';
if (s < 0.12 || Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) < 0.07) return 'neutral'; // warm/cool greys stay neutral
return h < 15 || h >= 345 ? 'red' : h < 40 ? 'orange' : h < 65 ? 'yellow' : h < 170 ? 'green'
: h < 195 ? 'teal' : h < 250 ? 'blue' : h < 290 ? 'purple' : 'pink';
}
const step = c => Math.min(950, Math.max(50, Math.round((1 - hsl(c).l) * 20) * 50));
// 1. Raw colors on the page
const uses = [];
let inInstances = 0, withStyle = 0;
function walk(n, inInst) {
for (const prop of ['fills', 'strokes']) {
if (!(prop in n)) continue;
const paints = n[prop];
if (paints === figma.mixed || !Array.isArray(paints)) continue;
const styleId = prop === 'fills' ? n.fillStyleId : n.strokeStyleId;
paints.forEach((p, i) => {
if (p.type !== 'SOLID' || p.visible === false || (p.boundVariables && p.boundVariables.color)) return;
if (inInst) { inInstances++; return; }
if (styleId && styleId !== figma.mixed) { withStyle++; return; }
const role = n.type === 'TEXT' && prop === 'fills' ? 'text' : prop === 'strokes' ? 'border' : 'bg';
uses.push({ node: n, prop, i, color: p.color, role });
});
}
if ('children' in n) for (const ch of n.children) walk(ch, inInst || n.type === 'INSTANCE');
}
for (const r of roots) { if (r.type === 'PAGE') { for (const ch of r.children) walk(ch, false); } else walk(r, false); }
if (!uses.length) {
console.log('Raw colors: 0. Skipped inside instances: ' + inInstances + ', with color styles: ' + withStyle);
ok('Nothing to fix: every color is already a token');
return;
}
// 2. Palette: merge near-identical colors, the most used one names the group
const palette = []; // { color, hex, count, uses[] }
const byHex = {};
for (const u of uses) { const h = toHex(u.color); (byHex[h] = byHex[h] || { color: u.color, hex: h, uses: [] }).uses.push(u); }
for (const g of Object.values(byHex).sort((a, b) => b.uses.length - a.uses.length)) {
const near = palette.find(p => dist(p.color, g.color) <= MERGE);
if (near) near.uses.push(...g.uses); else palette.push(g);
}
// 3. Primitive name per palette color: family/step (blue/500), white, black; collisions get -2, -3
const taken = {};
for (const p of palette) {
const f = family(p.color);
let name = f === 'white' || f === 'black' ? f : f + '/' + step(p.color);
let k = name, n = 2;
while (taken[k]) k = name + '-' + n++;
taken[k] = 1; p.prim = k;
}
// 4. Role tokens: per role, ordered by usage. Neutral and saturated colors get different names.
const NAMES = {
text: { neutral: ['primary', 'secondary', 'muted', 'disabled'], accent: ['accent', 'link', 'accent-2'], light: ['inverse'] },
bg: { neutral: ['canvas', 'surface', 'subtle', 'muted'], accent: ['primary', 'secondary', 'tertiary'], dark: ['inverse'] },
border: { neutral: ['default', 'subtle', 'strong'], accent: ['accent', 'focus'] },
};
const roles = {}; // 'text|blue/500' -> { role, prim, color, uses[] }
for (const p of palette) for (const u of p.uses) {
const key = u.role + '|' + p.prim;
(roles[key] = roles[key] || { role: u.role, p, uses: [] }).uses.push(u);
}
const used = {};
const roleList = Object.values(roles).sort((a, b) => b.uses.length - a.uses.length);
for (const r of roleList) {
const { l } = hsl(r.p.color), accent = !/^(neutral|white|black)/.test(r.p.prim);
const kind = accent ? 'accent' : r.role === 'text' && l > 0.85 ? 'light' : r.role === 'bg' && l < 0.2 ? 'dark' : 'neutral';
const list = NAMES[r.role][kind] || NAMES[r.role].neutral;
const group = r.role === 'bg' && kind === 'accent' ? 'action' : r.role;
const idx = (used[group + kind] = (used[group + kind] || 0) + 1) - 1;
r.name = group + '/' + (list[idx] || list[list.length - 1] + '-' + (idx - list.length + 2));
}
// 5. Report (and stop here if APPLY is false)
console.table(roleList.map(r => ({ token: r.name, primitive: r.p.prim, hex: r.p.hex, layers: r.uses.length })));
console.log(palette.length + ' colors -> ' + palette.length + ' primitives, ' + roleList.length + ' role tokens. ' +
'Skipped: ' + inInstances + ' inside instances, ' + withStyle + ' with color styles.');
if (!APPLY) { ok('Plan only: set APPLY = true to create and bind'); return; }
// 6. Collections and variables (reused by name if they already exist)
const cols = await figma.variables.getLocalVariableCollectionsAsync();
const colP = cols.find(c => c.name === 'Brand · Primitives') || figma.variables.createVariableCollection('Brand · Primitives');
const colT = cols.find(c => c.name === 'Brand · Theme') || figma.variables.createVariableCollection('Brand · Theme');
const all = await figma.variables.getLocalVariablesAsync('COLOR');
const find = (col, name) => all.find(v => v.variableCollectionId === col.id && v.name === name);
const SCOPES = { text: ['TEXT_FILL'], border: ['STROKE_COLOR'], bg: ['FRAME_FILL', 'SHAPE_FILL'], action: ['FRAME_FILL', 'SHAPE_FILL'] };
let created = 0, bound = 0;
for (const p of palette) {
let v = find(colP, p.prim);
if (!v) { v = figma.variables.createVariable(p.prim, colP, 'COLOR'); if (!all.includes(v)) all.push(v); created++; }
v.setValueForMode(colP.defaultModeId, { r: p.color.r, g: p.color.g, b: p.color.b, a: 1 });
try { v.scopes = []; } catch (e) {} // primitives: hidden from the color picker, use roles instead
p.v = v;
}
for (const r of roleList) {
let v = find(colT, r.name);
if (!v) { v = figma.variables.createVariable(r.name, colT, 'COLOR'); if (!all.includes(v)) all.push(v); created++; }
v.setValueForMode(colT.defaultModeId, figma.variables.createVariableAlias(r.p.v));
try { v.scopes = SCOPES[r.name.split('/')[0]]; } catch (e) {}
for (const u of r.uses) {
const paints = u.node[u.prop].slice();
paints[u.i] = figma.variables.setBoundVariableForPaint(paints[u.i], 'color', v);
u.node[u.prop] = paints;
bound++;
}
}
console.log('Rename freely in Variables: layers stay bound. Run Audit colors again to check.');
ok(palette.length + ' primitives, ' + roleList.length + ' role tokens, ' + bound + ' paints bound (' + created + ' new variables)');Розбери файл: що в ньому насправді
Скрипт нічого не змінює, тільки рахує: шрифти і розміри, тексти без стилів, відступи не по сітці 4 px, радіуси, скільки кольорів на токенах, які компоненти використовуються і які фрейми схожі на відірвані від компонентів копії.
// UX Hero · Code Runner: what is in this file
// Read-only report: fonts and sizes, spacing and radii, colors on tokens vs raw hex,
// components and detached copies. Changes nothing. Scope: selection or the whole page.
const roots = figma.currentPage.selection.length ? [...figma.currentPage.selection] : [...figma.currentPage.children];
const count = (map, key) => { map[key] = (map[key] || 0) + 1; };
const top = (map, n = 12) => Object.entries(map).sort((a, b) => b[1] - a[1]).slice(0, n);
const fonts = {}, sizes = {}, gaps = {}, pads = {}, radii = {}, comps = {};
let layers = 0, texts = 0, textNoStyle = 0, paintsBound = 0, paintsRaw = 0, frames = 0, autoLayout = 0, instances = 0, detached = 0;
function walk(n) {
layers++;
if (n.type === 'TEXT') {
texts++;
if (!n.textStyleId) textNoStyle++;
if (n.fontName !== figma.mixed) count(fonts, n.fontName.family + ' ' + n.fontName.style);
if (n.fontSize !== figma.mixed) count(sizes, n.fontSize + ' px');
}
for (const prop of ['fills', 'strokes']) {
if (!(prop in n) || !Array.isArray(n[prop])) continue;
for (const p of n[prop]) if (p.type === 'SOLID' && p.visible !== false) (p.boundVariables && p.boundVariables.color) ? paintsBound++ : paintsRaw++;
}
if (n.type === 'FRAME' || n.type === 'COMPONENT') {
frames++;
if (n.layoutMode && n.layoutMode !== 'NONE') {
autoLayout++;
count(gaps, n.itemSpacing);
[n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft].forEach(v => count(pads, v));
}
if (n.type === 'FRAME' && /^(button|btn|input|card|badge|chip|tag)/i.test(n.name)) detached++;
}
if ('cornerRadius' in n && n.cornerRadius !== figma.mixed && n.cornerRadius > 0) count(radii, n.cornerRadius);
if (n.type === 'INSTANCE') { instances++; count(comps, n.name.split(/[\/,=]/)[0].trim()); return; }
if ('children' in n) n.children.forEach(walk);
}
roots.forEach(walk);
const off4 = obj => Object.keys(obj).map(Number).filter(v => v % 4 !== 0);
console.log('Layers ' + layers + ' · frames ' + frames + ' (' + autoLayout + ' with auto layout) · instances ' + instances);
console.log('Colors: ' + paintsBound + ' on tokens, ' + paintsRaw + ' raw hex (' + Math.round(paintsBound / Math.max(1, paintsBound + paintsRaw) * 100) + '% tokenized)');
console.log('Text: ' + texts + ' layers, ' + textNoStyle + ' without a text style');
console.table(top(fonts).map(([font, n]) => ({ font, layers: n })));
console.table(top(sizes).map(([size, n]) => ({ size, layers: n })));
console.table(top(gaps).map(([gap, n]) => ({ gap, frames: n, grid4: gap % 4 === 0 ? 'ok' : 'off' })));
console.log('Paddings off the 4 px grid: ' + (off4(pads).join(', ') || 'none') + ' · radii used: ' + Object.keys(radii).join(', '));
console.table(top(comps).map(([component, n]) => ({ component, instances: n })));
if (detached) console.warn(detached + ' frames are named like components (button, card...) but are not instances: likely detached');
ok(layers + ' layers analysed: ' + Math.round(paintsBound / Math.max(1, paintsBound + paintsRaw) * 100) + '% colors on tokens, ' + Object.keys(sizes).length + ' font sizes');Звіт - готовий список задач. Кожен рядок можна віддати Claude: «ось звіт, напиши скрипт, який виправить відступи 13 і 15 на 12 і 16».
Що ще можна зробити: екрани, компоненти, сторінки
Готові сніпети в самому плагіні - один клік, без Claude: Button, Input, Card, Badge, Avatar, Toast, Palette, Audit colors. Кожен створює компонент з усіма станами в auto layout.
Свої задачі - через кнопку Copy prompt for Claude: вона копіює інструкцію, як писати скрипти саме для Code Runner. Встав її в Claude, допиши задачу своїми словами - отримаєш код, який одразу запускається. Готові задачі:
Напиши скрипт для Code Runner (Figma Plugin API, async).
Створи на поточній сторінці 2 екрани 390×844 поруч:
- «Вхід»: лого, поля Email і Пароль, кнопка «Увійти», лінк «Забув пароль».
- «Головна»: привітання, 3 картки зі статистикою, список з 5 операцій.
Усе в auto layout, відступи кратні 8, тексти українською.
Кольори бери зі змінних колекції «Brand · Theme» (bg/canvas, text/primary,
action/primary...) і прив'язуй через setBoundVariableForPaint.
Якщо такої колекції немає - використай DS з хелперів.Напиши скрипт для Code Runner (Figma Plugin API, async).
Створи component set Input: стани Default, Focus, Error, Disabled.
Властивості компонента: Label (текст) і Show hint (boolean).
Висота 44, радіус 8, auto layout. Кольори - зі змінних «Brand · Theme».Напиши скрипт для Code Runner (Figma Plugin API, async).
Створи вайрфрейм лендингу 1440 px: hero з заголовком і кнопкою,
3 картки переваг, 3 тарифи, FAQ з 4 питань, футер.
Картинки - сірі прямокутники з підписом, що там має бути.
Усе в auto layout, сітка 12 колонок, тексти українською.Напиши скрипт для Code Runner (Figma Plugin API, async).
На виділеному фреймі є список або таблиця. Перший рядок - шаблон.
Продублюй його до 20 рядків і заповни правдоподібними даними українською:
ім'я, email, роль, статус (Активний / Запрошено / Вимкнено).Напиши скрипт для Code Runner (Figma Plugin API, async).
Перевір контраст кожного тексту на сторінці з фоном під ним
(WCAG AA: 4.5:1 для тексту до 18 px, 3:1 для більшого).
Виведи в консоль таблицю порушень: шар, колір, фон, контраст.
Виділи ці шари на канвасі.Напиши скрипт для Code Runner (Figma Plugin API, async).
Усім фреймам на виділенні без auto layout увімкни вертикальний auto layout:
gap 16, padding 24, порядок дочірніх шарів - за координатою y.
Шари всередині інстансів не чіпай. Виведи, скільки фреймів змінив.Напиши скрипт для Code Runner (Figma Plugin API, async).
Перейменуй шари на виділеному фреймі за змістом.
- Тексти: перші 3-4 слова тексту.
- Фрейми з одним текстом і заливкою: button/<текст>.
- Картинки: image/<назва батьківського блоку>.
Решту не чіпай. У консоль виведи таблицю «було → стало».Напиши скрипт для Code Runner (Figma Plugin API, async).
На поточній сторінці знайди всі тексти розміром 13 px
і зроби їх 14 px з line-height 20 px. Шрифт перед зміною завантаж.
Тексти всередині інстансів не чіпай. Виведи, скільки змінив.Якщо скрипт впав - скопіюй помилку з консолі назад у Claude: «ось помилка, виправ». Зазвичай другий запуск уже працює.
Болванка дизайн-системи з нуля
Коли файлу ще немає - не треба нічого розбирати. Відкрий порожній файл і збери систему з одного кольору.
- Новий порожній файл → правий клік → Plugins → UX Hero: VooDoo Maker.
- Обери пресет або впиши свій Primary. Secondary, Error, Success, Warning - за бажанням, нейтральні VooDoo виведе сам.
- Справа обери Dark або Light - так виглядатиме превʼю і тема за замовчуванням.
- Generate design system. За хвилину: 150+ змінних у 3 колекціях (Primitives → Theme → Components), текстові стилі, 12 компонентів зі станами і сторінка-обкладинка.
На безкоштовній Figma дозволений лише 1 режим змінних, тож VooDoo будує ту тему, яку ти обрав. На платному плані будуть обидві: Light і Dark.
Сайт з посилання: вайрфрейм, потім картинки
Як я починаю редизайн: даю Claude посилання на поточний сайт і прошу спершу структуру, а вже потім картинки. Так видно, що на сторінці зайве, ще до того, як з'явився стиль.
Ось сайт: [посилання].
Перемалюй головну як вайрфрейм: ті самі блоки в тому ж порядку,
справжні тексти з сайту, сітка 12 колонок, ширина 1440.
Картинки - сірі плейсхолдери з підписом, що там має бути.
Без кольорів і стилю, тільки структура та ієрархія.Тепер постав картинки: у кожен плейсхолдер - зображення
під зміст свого блоку. Сітку, тексти і розміри не чіпай.
Покажи, що вийшло, і список блоків, де картинку варто замінити на справжню.Картинки від AI - для презентації ідеї. У фінальний макет став справжні фото клієнта.
Що далі
Обидва безкоштовні і працюють на безкоштовній Figma. Нові скрипти для Code Runner і промпти - у Telegram-боті, разом з анонсами ефірів.
VooDoo Maker ↗ Code Runner ↗Підписатися в боті ↗
Як побудувати систему, яка не розлітається: Variables, 3 рівні токенів, компоненти зі станами, Storybook і Claude. 7 днів, воркбук, відео і група.
Про курс ↗О 19:00 роблю щось руками на екрані, без слайдів. Реєстрація і нагадування в боті.
Усі ефіри ↗