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

Каталог - это таблица стилей
Обычно такая галерея хранит градиенты данными: массив объектов с цветами, углами и стопами, а превью и сниппет собираются из них двумя разными кусками кода. Тут наоборот.
Источник правды - src/styles/gradients.css, 3389 строк настоящих
CSS-правил. А src/data/colors.json - плоский индекс на 388 записей, и в
каждой всего четыре поля:
{
"id": "glow-aurora-cyan",
"name": "glow-aurora-cyan",
"index": 0,
"className": "radial-glow-aurora-cyan"
}Ни цветов, ни углов, ни стопов. Только имя класса, по которому в таблице стилей лежит настоящее правило.
Отсюда следует главное: превью на странице отрисовано ровно тем же правилом, которое отдает кнопка копирования. Не «сгенерировано из тех же данных», а буквально тем же. Разъехаться им нечем.
Сниппет выводится из CSS на сборке
src/data/css-snippets.ts на этапе сборки читает файл gradients.css, находит
в нем правило по имени класса и достает тело:
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() есть запятые и пробелы, которые делить
нельзя.
Поэтому там разбор по глубине скобок:
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(...)], как это и пишут руками.
Мелочь, но именно из таких мелочей складывается ощущение, что вставленный код не выглядит машинным.

Поиск
Поиск - 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 в шапке любой страницы, максимум восемь результатов со ссылками
сразу на карточки, и обычное поле на странице каталога. На страницах, где есть
поле, оверлей отдает ему горячую клавишу и остается только по клику.
Описания синтезируются
Интересная деталь: у градиентов нет написанных руками описаний. Они собираются из имени:
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. Назначаются они не вручную, а по префиксу имени:
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-градиент - оттенок, сам чип, затемнение, - то есть год можно вставить в макет одной строкой.

Маршруты
/ главная галерея
/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. Проверки, что у каждого градиента есть
осмысленный тег, нет. Один градиент с нестандартным именем исчезнет из всех
фильтров и не сообщит об этом.
