Несколько дней назад я сделал маленький инструмент для себя. Надоело каждый раз начинать новый проект с колеса оттенков: крутишь Coolors, листаешь Pinterest, смотришь на чужие палитры, которые «почти подходят». А ведь у проекта уже есть образ, это слово. Рассвет. Шторм. Тёмный лес. У каждого слова есть свой цвет, который чувствуешь интуитивно. Просто никто не переводит его в HEX за тебя.Так появился Колорит: вводишь слово, ИИ собирает палитру. Я написал про него на Хабре. А потом поймал себя на новой мысли. Палитра это ведь только начало. После цвета всё равно нужно имя, слоган, шрифты, ощущение движения. Почему всё это должно начинаться с нуля, если отправная точка одна и та же?Так Колорит перестал быть просто генератором палитр и стал маленьким конструктором бренда. Читать далее
Время на прочтение9 мин
Охват и читатели9.7K
Кейс
В прошлый раз я показывал Колорит, генератор палитр на голом Node.js и DeepSeek. Тогда там было два режима: «Слово → Цвет» и «Фото → Цвет». Выложил, собрал комментарии и поймал себя на простой мысли: палитра это же только начало. После цвета всё равно нужно имя, шрифты, отступы, ощущение движения. А отправная точка та же самая, одно слово.
За следующие несколько дней инструмент оброс мясом. Теперь вводишь слово, и дальше идёшь по шагам: палитра, нейминг, слоган, шрифтовые пары, система отступов и теней, анимации, мудборд, идеи для логотипа и собранный брендбук с экспортом в Figma, Adobe, CSS и PNG.
Расскажу про три вещи, которые показались самыми поучительными. Как из одного слова рождается весь бренд через один эндпоинт. Как я нашёл у себя XSS через кнопку «поделиться». И какие грабли собрал на деплое, когда сервер бодро рапортовал «обновился», а на сайте висела старая версия.
Стек остался скучным, и это сознательно. Бэкенд это небольшой сервер на голом модуле
http, без фреймворков и вообще без зависимостей. Фронтенд это одинindex.htmlна ванильном JS, без сборки. ИИ только DeepSeek. Состояние бренда живёт вlocalStorageи в хэше ссылки. Базы данных нет.
Конвейер из одного словаЛогика выстроена как пошаговый сценарий из девяти разделов. Важно тут одно: каждый следующий шаг помнит предыдущие. Палитра и выбранное имя влияют на промпт для слогана, слоган и цвета на подбор шрифтов, и так по цепочке. Это не девять независимых генераторов, а один разговор. Внутри это большой объект состояния S и набор функций рендера, никакого фреймворка, только innerHTML и точечные обновления DOM там, где важна отзывчивость.
Главное архитектурное решение второй версии было простым: не плодить эндпоинты. Палитра как ходила на /api/generate, так и осталась. А весь брендинг (нейминг, слоган, шрифты, токены, анимации) ходит в один /api/branding с полем type. Сервер по типу собирает нужный промпт и возвращает строго JSON-массив.
if (type === 'fonts') {
prompt =
`Подбери 6 шрифтовых пар для бренда с концепцией: «${concept.trim()}».\n` +
(paletteStr ? `Цветовая палитра: ${paletteStr}.\n` : '') +
`heading это выразительный шрифт для заголовков, body это читаемый шрифт для текста.\n` +
`mood это одно русское слово настроения, tags это 2-3 коротких русских тега.\n` +
`Только Google Fonts, разные стили. Верни ТОЛЬКО JSON-массив из 6 объектов: ` +
`[{"heading":"Playfair Display","body":"Lato","mood":"Элегантный","tags":["роскошь","классика"]}]`;
} else if (type === 'animations') {
prompt =
`Придумай 4 анимационных пресета для бренда: «${concept.trim()}».\n` +
`type: fade-up|fade-in|scale-up|slide-right|spring|float; duration: 0.2-1.4s.\n` +
`Верни ТОЛЬКО JSON-массив из 4 объектов: ` +
`[{"name":"Плавный вход","type":"fade-up","duration":0.5,"easing":"cubic-bezier(0.4,0,0.2,1)"}]`;
}
// и так же для naming и slogan и spacingТри приёма, которые перекочевали из первой версии и подтвердились на новых типах данных.
Первое: «ТОЛЬКО JSON» капсом всё ещё работает лучше вежливой просьбы. Модель по-прежнему норовит обернуть ответ в тройные кавычки с пометкой json. Второе: жёсткая схема прямо в промпте. Для шрифтов это поля heading, body, mood, tags, для анимаций фиксированный набор type и диапазон duration, для токенов baseSpacing из набора 4, 6, 8 и borderRadius в пределах от 0 до 24. Чем уже коридор значений, тем меньше сюрпризов на рендере. Третье, для палитры по слову: подмешиваю случайный угол зрения (природа, времена года, материалы, эмоции, география, живопись). Без этого «осень» стабильно выдаёт пять оттенков бежевого.
Сам вызов модели намеренно примитивный. DeepSeek совместим с OpenAI по протоколу, так что это один POST на один эндпоинт, одно сообщение и невысокая температура. Никакого function calling и схем, всё держится на тексте промпта и на проверке ответа.
async function callDeepSeek(msgContent) {
const res = await httpsReq({
hostname: 'api.deepseek.com',
path: '/v1/chat/completions',
method: 'POST',
headers: { Authorization: 'Bearer ' + DS_KEY, 'Content-Type': 'application/json' },
}, { model: DS_MODEL, messages: [{ role: 'user', content: msgContent }], temperature: 0.9 });
if (res.status !== 200) throw new Error(`DeepSeek error ${res.status}`);
return JSON.parse(res.body).choices[0].message.content;
}Та самая случайная «линза» из третьего приёма в коде это просто массив из восьми углов зрения, из которого на каждый запрос берётся один:
const WORD_LENSES = [
'природа и стихии: деревья, камни, вода, небо, туман',
'материалы и текстуры: бетон, замша, шёлк, ржавчина, мел',
'эмоции и состояния: тревога, нежность, усталость, ликование',
'живопись: охра, лазурь, кармин, сепия, кобальт',
// ...всего восемь
];
const lens = WORD_LENSES[Math.floor(Math.random() * WORD_LENSES.length)];Парсер ответа намеренно параноидальный. Я не доверяю обёрткам и выдёргиваю первый JSON-массив регуляркой:
const content = await callAI(prompt);
const match = content.match(/\[[\s\S]*\]/);
if (!match) throw new Error('Не удалось разобрать ответ AI');
const items = JSON.parse(match[0]);Шрифтовую пару после получения сразу подгружаю с Google Fonts. Ссылку собираю на лету, с кэшем уже загруженных URL, чтобы не дёргать одно и то же дважды:
function buildGFUrl(h, b) {
const enc = n => encodeURIComponent(n).replace(/%20/g, '+');
let url = 'https://fonts.googleapis.com/css2?family=' + enc(h) + ':wght@400;700';
if (b && b !== h) url += '&family=' + enc(b) + ':wght@300;400;700';
return url + '&display=swap';
}Отдельная мелочь, которая бесила пару вечеров: ползунки размера шрифта и отступов «не тянулись». Оказалось, на каждое событие input я перерисовывал всю карточку целиком, и узел ползунка пересоздавался прямо под пальцем, драг рвался. Лечится точечным обновлением: меняем только нужные textContent и style, сам слайдер не трогаем.
Фото в Цвет: пять алгоритмов вместо одногоВ первой версии режим «Фото → Цвет» был один общий k-means. Быстро выяснилось, что доминирующие цвета у пейзажа, портрета и тарелки с едой это разные вещи. На портрете важны тона кожи, на еде сочные акценты соуса, а не серый фон тарелки, на пейзаже небо и земля идут полосами. Поэтому теперь под тип фото свой экстрактор:
switch (photoType) {
case 'пейзаж': return extractLandscape(data, W, H, k);
case 'портрет': return extractPortrait(data, W, H, k);
case 'интерьер': return extractInterior(data, W, H, k);
case 'еда': return extractFood(data, W, H, k);
default: return extractAbstract(data, W, H, k);
}Вся обработка идёт в браузере, через Canvas. Фото уменьшается до 250px по большей стороне, этого хватает для доминирующих цветов и держит время разбора на десятках миллисекунд. На сервер картинка не уходит вообще, ни по соображениям приватности, ни по трафику. При переключении типа свотчи пересчитываются мгновенно, ещё до обращения к ИИ, так что разницу между «пейзажем» и «едой» видно сразу. А сам тип потом уходит и в промпт для названий: для портрета модель просят про телесные и эмоциональные образы, для интерьера про материалы и текстуры.
Честный контраст: немного математики WCAGПод каждым цветом стоит бейдж вроде «AAA 14.3». Это не украшение, а реальный коэффициент контраста между цветом и текстом на нём, по формуле WCAG. Генератор палитр, который про контраст не думает, легко выдаёт красивые, но нечитаемые сочетания. Поэтому считаю его честно: перевод sRGB в линейное пространство, относительная яркость с весами по восприятию, отношение яркостей.
function relativeLuminance(hex) {
const {r,g,b} = hexToRgbObj(hex);
const lin = c => { c/=255; return c<=0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); };
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
}
function contrastRatio(h1, h2) {
const l1 = relativeLuminance(h1), l2 = relativeLuminance(h2);
return (Math.max(l1,l2) + 0.05) / (Math.min(l1,l2) + 0.05);
}
function wcagLabel(r) {
if (r >= 7) return 'AAA'; // отлично
if (r >= 4.5) return 'AA'; // норма
return r >= 3 ? 'AA↑' : '✗'; // только для крупного текста, либо провал
}Веса 0.2126, 0.7152 и 0.0722 это не магия, а вклад каналов в воспринимаемую яркость: глаз сильно чувствителен к зелёному и слабо к синему. Поэтому одинаковые по «силе» зелёный и синий дают разный контраст, и считать на глаз бесполезно.
Экспорт в .ase руками, по байтамПалитру можно выгрузить в Adobe Swatch Exchange и открыть прямо в Photoshop или Illustrator. Формат бинарный, и тащить ради него библиотеку не хотелось, так что пишу его руками через ArrayBuffer и DataView, big-endian. Заголовок «ASEF», версия, число цветов, дальше по блоку на цвет: тип блока, имя в UTF-16, модель «RGB » и три компонента как Float32.
const v = new DataView(buf);
let o = 0;
// заголовок: "ASEF" + версия 1.0 + число цветов
[0x41,0x53,0x45,0x46].forEach(b => v.setUint8(o++, b));
v.setUint16(o, 1, false); o += 2;
v.setUint16(o, 0, false); o += 2;
v.setUint32(o, colors.length, false); o += 4;
encoded.forEach(({ c, chars, nameLen, blockData }) => {
const r = parseInt(c.hex.slice(1,3),16) / 255;
const g = parseInt(c.hex.slice(3,5),16) / 255;
const b = parseInt(c.hex.slice(5,7),16) / 255;
v.setUint16(o, 0x0001, false); o += 2; // тип блока: цвет
v.setUint32(o, blockData, false); o += 4; // длина блока
v.setUint16(o, nameLen, false); o += 2; // длина имени
chars.forEach(cp => { v.setUint16(o, cp, false); o += 2; }); // имя в UTF-16
v.setUint16(o, 0, false); o += 2; // null-терминатор
[0x52,0x47,0x42,0x20].forEach(b => v.setUint8(o++, b)); // "RGB "
v.setFloat32(o, r, false); o += 4;
v.setFloat32(o, g, false); o += 4;
v.setFloat32(o, b, false); o += 4;
v.setUint16(o, 0x0000, false); o += 2; // global color
});Рядом так же руками собираются CSS-переменные (цвета плюс выбранные шрифты и токены) и SVG-брендбук. Никакой магии, просто аккуратная сборка строк и байтов.
Безопасность: как я сам себе подложил XSSКогда инструмент стал собирать целый бренд, появилась кнопка «Поделиться». Всё состояние упаковывается в хэш ссылки:
const enc = btoa(encodeURIComponent(JSON.stringify(brand)));
const url = location.href.split('#')[0] + '#b=' + enc;Открываешь такую ссылку, состояние распаковывается обратно в палитру, слоганы, шрифты и рендерится в DOM. Вот тут и дыра. Названия цветов, имена и слоганы я вставлял в разметку сырыми:
'<div class="slogan-text">' + slogan + '</div>' // былоА содержимое хэша полностью контролирует тот, кто сформировал ссылку. То есть можно собрать ссылку, где «название цвета» это <img src=x>, прислать её жертве, и при открытии выполнится чужой JS в контексте сайта. Классический reflected-XSS, который я сам себе и сделал, добавив «поделиться».
Чинил в два эшелона. Первый: экранирование на выводе везде, где в DOM попадает текст от ИИ или из ссылки.
'<div class="slogan-text">' + escHtml(slogan) + '</div>' // сталоВторой, главный: строгая проверка данных из ссылки на входе, ещё до рендера. Hex по жёсткой регулярке, текст чистится от опасных символов и режется по длине, количество элементов ограничено.
function _sec_clean(s) {
return typeof s === 'string' ? s.replace(/[<>"'`]/g, '').slice(0, 200) : '';
}
function _sec_colors(arr) {
return Array.isArray(arr)
? arr.filter(c => c && /^#[0-9a-fA-F]{6}$/.test(c.hex)).slice(0, 12)
.map(c => ({ hex: c.hex, name: _sec_clean(c.name) }))
: [];
}Заодно прошёлся по серверу и закрыл то, что в пет-проектах обычно оставляют «на потом». Эндпоинты ИИ висели с Access-Control-Allow-Origin: * и без проверки источника, то есть любой мог дёргать прокси и жечь мои токены DeepSeek. Убрал звёздочку, добавил проверку Origin и Referer против своего хоста и глобальный лимит запросов поверх пер-айпишного. Лимитер, кстати, брал IP из X-Forwarded-For, который клиент задаёт сам, так что лимита фактически не было. Теперь этот заголовок учитывается только при явном TRUST_PROXY=1, иначе берётся реальный IP сокета. Ещё убрал rejectUnauthorized: false в исходящих запросах и добавил USER node в Dockerfile, чтобы контейнер не крутился от root. Ничего экзотического, но именно этот набор и забывают чаще всего.
Самое интересное случилось на выкладке. Приложение крутится в Docker-контейнере на 127.0.0.1:3002, а nginx проксирует на него путь /colorit/. Схема обычная, но граблей я собрал три.
Палитра генерировалась, а нейминг, слоган и шрифты падали. В Chrome ошибка Unexpected token '<', в Safari The string did not match the expected pattern. Это одно и то же: JSON-парсер подавился HTML. Виноваты абсолютные пути.
fetch('/api/branding', ...) // уходит на konstmax.ru/api/branding, мимо /colorit/С корня запрос шёл не в контейнер, а в WordPress основного сайта, который отдавал HTML-страницу. Палитра «работала» лишь потому, что её запрос случайно был относительным. Лечение: относительные пути ко всем эндпоинтам, чтобы запрос шёл в /colorit/api/... и nginx довёл его до контейнера.
fetch('api/branding', ...) // уходит на /colorit/api/branding2. Docker молча отдавал старый образДеплой собирает образ локально, делает docker save в tar, заливает по scp, потом docker load и пересоздание контейнера. Контейнер исправно пересоздавался, статус «Up 4 minutes», а код внутри был старый. Виновата новая сборка Docker Desktop с containerd image store: по умолчанию она делает манифест-лист с аттестациями, то есть мультиплатформенный образ. Такой образ через save и load на классическом демоне сервера не обновляет тег, и docker run запускает старый слой. В выводе сборки это видно по строкам про exporting attestation manifest и exporting manifest list. Лечится сборкой в классическом формате:
docker build --provenance=false --sbom=false -t colorit .Коварство в том, что локально всё выглядело свежим. В деве исходники примонтированы как volume и подменяют файлы в контейнере поверх образа. То есть локальный контейнер показывал новый код, а сам образ оставался старым, и это вскрывается только на проде, где монтирования нет. Вывод на будущее: проверять не контейнер, а содержимое образа через docker run --rm colorit:latest cat /app/....
Тема из прошлой статьи получила продолжение. navigator.share и navigator.clipboard доступны только в защищённом контексте, то есть по HTTPS или на localhost. Когда тестируешь с телефона по локальному IP вида http://192.168.x.x, их просто нет, и кнопки «поделиться» и «копировать» молча падали. Сделал универсальный помощник копирования с фолбэком на execCommand('copy'), а сохранение PNG через blob-URL, плюс запасной вариант с открытием картинки в новой вкладке для «сохранить долгим тапом».

Итог и что дальшеКолорит из «слово в палитру» превратился в «слово в бренд»: девять шагов, экспорт в Figma, Adobe, CSS и PNG, брендбук на печать, двуязычный интерфейс, светлая и тёмная тема и аккуратный mobile-first адаптив. Фронтенд по-прежнему без зависимостей, бэкенд без фреймворков.
Это второй модуль задуманной экосистемы «Слово-система», набора инструментов, где точка входа всегда обычное слово. Колорит покрывает визуальную идентику целиком. На очереди модули, которые продолжают ту же идею: помогать на самом раннем этапе, когда есть только ощущение и ни одного пикселя.
Потрогать можно тут: konstmax.ru/colorit
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как я сделал генератор палитр на Node.js + DeepSeek за два вечера - и что из этого вышло | 0 | 8.89 | 23-06-2026 |
| 2 | Собрал мультибрендовую дизайн-систему аж для семи брендов | 0 | 7.42 | 05-06-2026 |
| 3 | Эволюция токенов цветов в Туту: как навести порядок в дизайне и не уехать в дурку | 0 | 8.51 | 16-06-2026 |
| 4 | Как перенести навайбкоженный проект в Figma через Claude Code и получить дизайн‑систему | 0 | 8.28 | 25-08-2026 |
| 5 | Чтобы не выглядело как пет-проект»: как я в одиночку сделал премиальный интерфейс кино-сервиса (с кодом) | 0 | 10.32 | 21-05-2026 |
| 6 | Генерируем фотореалистичные изображения | 0 | 9.6 | 08-06-2026 |
| 7 | DS MCP: как дать AI-агенту знание о вашей дизайн-системе | 7 | 8 | 08-07-2026 |
| 8 | Figma больше не первый инструмент дизайнера. И это не выбор | -5 | 6 | 07-07-2026 |
| 9 | Figma больше не первый инструмент дизайнера. И это не выбор | 0 | 7.61 | 07-07-2026 |
| 10 | Как я измеряю видимость брендов в шести нейросетях: схема замера, формат логов и ошибки | 0 | 7.38 | 18-08-2026 |