В прошлый раз я показывал Колорит, генератор палитр на голом 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/. Схема обычная, но граблей я собрал три.
1. Сабпат сломал все запросы к API
Палитра генерировалась, а нейминг, слоган и шрифты падали. В 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/branding
2. 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/....
3. iOS и защищённый контекст
Тема из прошлой статьи получила продолжение. 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
