color.xima.work - галерея CSS-градиентов. Заходишь с открытым редактором, глазами находишь нужный, копируешь код в формате CSS3 или Tailwind v4 и уходишь. Сессии короткие, и весь сайт построен вокруг этого сценария.

Внутри 388 градиентов и одно архитектурное решение, из которого следует все остальное.

color.xima.workГалерея градиентов: поиск, фильтры, копирование в CSS3 или Tailwind.код
Главная color.xima.work: заголовок, кнопка на 388 градиентов и лента превью под ней
Главная color.xima.work: заголовок, кнопка на 388 градиентов и лента превью под ней

Каталог - это таблица стилей

Обычно такая галерея хранит градиенты данными: массив объектов с цветами, углами и стопами, а превью и сниппет собираются из них двумя разными кусками кода. Тут наоборот.

Источник правды - src/styles/gradients.css, 3389 строк настоящих CSS-правил. А src/data/colors.json - плоский индекс на 388 записей, и в каждой всего четыре поля:

JSON
{
	"id": "glow-aurora-cyan",
	"name": "glow-aurora-cyan",
	"index": 0,
	"className": "radial-glow-aurora-cyan"
}

Ни цветов, ни углов, ни стопов. Только имя класса, по которому в таблице стилей лежит настоящее правило.

Отсюда следует главное: превью на странице отрисовано ровно тем же правилом, которое отдает кнопка копирования. Не «сгенерировано из тех же данных», а буквально тем же. Разъехаться им нечем.

Сниппет выводится из CSS на сборке

src/data/css-snippets.ts на этапе сборки читает файл gradients.css, находит в нем правило по имени класса и достает тело:

TypeScript
const ruleRegex = new RegExp(
	`(^|\\n)\\s*\\.${escapedClass}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`,
	"m",
);

Дальше тело разбирается на пары «свойство - значение», из них собирается аккуратный CSS3-сниппет, а параллельно - вариант на Tailwind v4. Оба подсвечиваются через Shiki там же, на сборке, так что в браузер уезжает готовый HTML без рантайм-подсветки.

Регулярка по CSS обычно плохая идея, но здесь она работает на своем же файле с известным форматированием, а не на произвольном вводе. Цена ошибки видна сразу: не нашлось правило - в сниппет попадет /* style not found */.

Конвертер в Tailwind

Самая нетривиальная часть - перевод обычных деклараций в произвольные утилиты Tailwind v4. Наивная замена пробелов на подчеркивания ломается на первом же градиенте: внутри radial-gradient() есть запятые и пробелы, которые делить нельзя.

Поэтому там разбор по глубине скобок:

TypeScript
function splitTopLevel(value: string, separator: "," | " "): string[] {
	let depth = 0;
	for (const character of value) {
		if (character === "(" || character === "[") depth += 1;
		else if (character === ")" || character === "]") depth -= 1;
		const isSeparator = depth === 0 && /* ... */ false;
	}
}

Отдельно разбирается сокращенная запись рамки: 1px solid rgba(...) превращается не в один класс [border:...], а в три - border, border-solid и border-[rgba(...)], как это и пишут руками.

Мелочь, но именно из таких мелочей складывается ощущение, что вставленный код не выглядит машинным.

Страница /cards: поиск, чипы тегов со счетчиками и сетка карточек с кнопкой копирования
Страница /cards: поиск, чипы тегов со счетчиками и сетка карточек с кнопкой копирования

Поиск

Поиск - Fuse.js прямо в браузере, с индексом, вшитым в страницу. Шесть полей с весами:

Поле Вес
name 0.42
description 0.24
tags 0.16
className 0.1
visibleId 0.05
id 0.03

Порог нечеткости 0.36 и ignoreLocation: true - совпадение засчитывается в любом месте строки, а не только в начале.

Поисков на сайте два, и они не мешают друг другу: глобальный оверлей на Cmd/Ctrl+K в шапке любой страницы, максимум восемь результатов со ссылками сразу на карточки, и обычное поле на странице каталога. На страницах, где есть поле, оверлей отдает ему горячую клавишу и остается только по клику.

Описания синтезируются

Интересная деталь: у градиентов нет написанных руками описаний. Они собираются из имени:

TypeScript
export function getDescription(name: string, id: number): string {
	const usage = usages[(id * 3 + 1) % usages.length];
	const type = getTypeLabel(name);
	const palette = getPalette(name);

	return clamp(`${type} with ${palette} palette, optimized ${usage}.`, "for modern UI");
}

Тип берется из префикса имени, палитра - из оставшихся токенов, а фраза о применении выбирается детерминированно по id, то есть у одного градиента она всегда одна и та же. Список фраз конечный: «для героев с контрастной типографикой», «для карточек товара», «для темных дашбордов» и еще несколько.

Решение спорное, но честное: почти четыре сотни описаний руками никто не напишет, а детерминированная генерация хотя бы не меняет текст между сборками и дает поиску дополнительное поле для совпадений.

Теги

Четырнадцать тегов: glow, mesh, linear, radial, conic, repeating, multi-stop, transparent, border, noise, femme, masc, flag, other. Назначаются они не вручную, а по префиксу имени:

TypeScript
if (name.startsWith("flag-")) return ["flag"];
if (name.startsWith("mesh-neo-") || name.startsWith("mesh-")) return ["mesh"];
if (name.startsWith("glow-")) return ["glow"];

Дисциплина именования заменяет метаданные. Пока имена соблюдают соглашение, теги, фильтры и коллекции получаются сами. Как только имя выбьется из соглашения, градиент упадет в other - молча.

Чистка каталога

Свежая правка каталога хорошо показывает, чем такая архитектура удобна. Из 465 записей ушли 162, добавились 85, переименованы 66 - на выходе 388. Обоснование каждой правки лежит рядом, в PRUNE.md, а не тонет в диффе: побайтовые дубликаты, почти неразличимые пары, градиенты с именами, которые никто не найдет поиском.

Поскольку каталог - это таблица стилей, чистка свелась к удалению правил из CSS и записей из индекса. Ни превью, ни сниппеты пересобирать не пришлось: они и так выводятся из того же файла.

Pantone Color of the Year

Новый раздел /pantone/: цвет года Pantone с 2000-го, каждый с кодом TCX, hex и RGB. Два года выходили парой цветов, 2016 и 2021, и рендерятся раздельной плашкой.

Интересна честность в комментарии к данным: Pantone подбирает цвета для красок и текстиля, поэтому каждый hex тут - общепринятое приближение чипа TCX в sRGB, а не точная конвертация, и два источника могут разойтись на пару пунктов.

Каждый чип еще и превращается в трехстоповый CSS-градиент - оттенок, сам чип, затемнение, - то есть год можно вставить в макет одной строкой.

Раздел Pantone: цвет 2026 года Cloud Dancer крупной плашкой и полоса из 27 лет под ней
Раздел Pantone: цвет 2026 года Cloud Dancer крупной плашкой и полоса из 27 лет под ней

Маршруты

TXT
/                     главная галерея
/cards/               все карточки с кнопками копирования
/gradient/[slug]/     страница одного градиента
/collection/[tag]/    коллекция по тегу
/generator/           генератор: блок, текст, пресеты
/generator/mesh/      меш-градиенты
/generator/instagram/ пост для инстаграма
/pantone/             цвет года Pantone с 2000-го
/picks/               подборки
/daily/               редирект на градиент дня
/random/              редирект на случайный
/favorites/           избранное

/daily/ и /random/ - редиректы, а не страницы: на статике это дешевле, чем рендерить одно и то же под разными адресами.

Статика и деплой

Astro 5 в статическом режиме, Tailwind v4, деплой на Vercel. Кеширование задается одним правилом в vercel.json - неделя плюс stale-while-revalidate на все ответы.

OG-картинка одна на весь сайт, статическая, пересобирается скриптом bun run og. Для галереи это разумно: рисовать превью каждого градиента в Open Graph - большая работа ради страниц, которыми делятся редко.

Лицензии

Две, и это правильно:

  • код репозитория - MIT;
  • сами градиенты - CC0, то есть общественное достояние. Брать в любой проект, включая коммерческий, без указания авторства.

Для галереи, у которой продукт - это как раз сниппеты, вторая лицензия важнее первой. Без нее непонятно, можно ли вообще пользоваться скопированным.

Что цепляет глаз

Три наблюдения по коду, не критика, а заметки.

Поиск ищет по именам, а не по цвету. В индексе нет ни оттенка, ни светлоты - только строки. Запрос cyan найдет glow-aurora-cyan, потому что слово есть в имени, а вот «что-нибудь холодное и темное» спросить нечем. Для галереи, где основной способ выбора - глазами, это осознанный размен, но потолок у текстового поиска тут виден.

wrangler в зависимостях при деплое на Vercel. Рядом стоит @astrojs/vercel, а конфиг деплоя - vercel.json. Похоже на след от прошлой попытки уехать на Cloudflare.

Теги молча падают в other. Проверки, что у каждого градиента есть осмысленный тег, нет. Один градиент с нестандартным именем исчезнет из всех фильтров и не сообщит об этом.

ИсходникиAstro 5, Tailwind v4, Fuse.js. Код под MIT, градиенты под CC0.