В прошлый раз я показывал Колорит, генератор палитр на голом 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