@labpics/colors
v0.10.0
Published
Contextual compiler and runtime resolver for client-owned color-token contracts. WASM core, zero runtime dependencies.
Maintainers
Readme
@labpics/colors
Агностичный контраст-движок для дизайн-систем. Получает фоновый цвет и тему — возвращает полный набор цветовых ролей вашей системы. Словарь ролей не встроен в пакет: его задаёт конфиг дизайн-системы (ThemeConfig), загружаемый через loadConfig; имена вида --lab-label-primary, --lab-border-base в примерах ниже — из конфига дизайн-системы labui. CSS-переменные несут готовое значение oklch(L% C H) (для полупрозрачных ролей — oklch(L% C H / A)); сырой #RRGGBB остаётся данными роли (roles.<ключ>.hex). Ядро написано на Rust и скомпилировано в WebAssembly; пакет не имеет runtime-зависимостей.
Ядро возвращает данные, не затрагивает DOM. Три вспомогательные функции переводят эти данные в живые CSS-переменные: applyTheme (разовое применение), watchTheme (реактивное — обновляется при изменении фона) и adaptTheme (плавная адаптация для фона, меняющегося каждый кадр).
Установка
Пакет публикуется в npm-реестр Labpics:
npm install @labpics/colorsПри сборке из монорепо:
npm run build # → pkg/ (wasm + JS-обёртка + .d.ts)Пакет экспортирует @labpics/colors/build-metadata.json — self-declared
machine-readable metadata конкретной сборки: npm/core versions, exact source
SHA, digest и SHA-256 conformance manifest/family set, а также размер и SHA-256
WASM-байтов. Release-gate сверяет объект целиком с исходными файлами и повторяет
проверку после чистой установки tarball. Это integrity metadata внутри
артефакта, не криптографическая provenance/Sigstore-аттестация, не runtime
telemetry и не сетевой запрос.
Как использовать
Разовое применение
import init, { LabColors, applyTheme } from "@labpics/colors";
import dsConfig from "./theme.config.json"; // паспорт вашей дизайн-системы (ThemeConfig)
await init(); // загрузить WASM-модуль (один раз)
const engine = new LabColors(); // пустой движок: словаря ролей ещё нет
engine.loadConfig(JSON.stringify(dsConfig)); // без этого шага resolveTheme отклонится: config_required
const result = engine.resolveTheme("#FFFFFF", "light");
// result.vars → готовые CSS-значения по ролям ВАШЕГО конфига:
// { "--lab-label-primary": "oklch(<L>% <C> <H>)", … }
// result.roles → детали каждой роли (css, hex, контраст, флаги)
applyTheme(document.documentElement, result); // записать все --lab-* в элементРеактивное отслеживание
watchTheme синхронизирует переменные с фактическим фоном элемента автоматически.
import init, { LabColors, watchTheme } from "@labpics/colors";
await init();
const colors = new LabColors();
colors.loadConfig(JSON.stringify(dsConfig)); // конфиг дизайн-системы (см. квик-старт)
const panel = document.querySelector(".panel") as HTMLElement;
const watcher = watchTheme(panel, { colors, theme: "light" });
// panel теперь несёт правильные --lab-* для своего фона
watcher.setTheme("dark"); // переключить тему
watcher.refresh(); // принудительная пересинхронизация
watcher.stop(); // отключить наблюдателяПоддерживает два режима изменений:
- Дискретные (переключение темы, обновление класса, DOM-мутация) — отслеживаются
MutationObserverавтоматически. - Непрерывные (CSS-анимация, кадровый фон, который никогда не мутирует
inline style) — управляются вручную черезrefresh()из собственного циклаrequestAnimationFrame.refresh()дёшев: пересчитывает только при реальном изменении строки фона.
Плавная адаптация к анимированному фону
watchTheme пересчитывает весь набор при каждом изменении фона. Для фона, меняющегося каждый кадр (анимация, параллакс, размытый фон), adaptTheme — более элегантный вариант: проверяет дёшево каждый кадр, пересчитывает и плавно переходит к новым цветам только когда контраст реально ухудшается.
import init, { LabColors, adaptTheme, effectiveBackground } from "@labpics/colors";
await init();
const colors = new LabColors();
colors.loadConfig(JSON.stringify(dsConfig)); // конфиг дизайн-системы (см. квик-старт)
const surface = document.querySelector(".hero") as HTMLElement;
const adaptive = adaptTheme(surface, {
colors,
theme: "light",
background: () => effectiveBackground(surface, { fallback: "#101012" }),
});
adaptive.start(); // запустить внутренний requestAnimationFrame-цикл
adaptive.setTheme("dark"); // смена темы применяется мгновенно
adaptive.stop(); // остановить циклДля фона с градиентом или изображением можно передать несколько образцов — цвета будут корректны для наиболее сложного из них:
adaptTheme(hero, {
colors,
theme: "light",
strict: true,
background: () => sampleBackdrop(hero), // например ["#0B0B0E", "#3A3A40"]
});Темы
| Имя темы | Назначение |
|----------|------------|
| "light" | Светлая тема |
| "dark" | Тёмная тема |
| "light-ic" | Светлая с повышенным контрастом |
| "dark-ic" | Тёмная с повышенным контрастом |
Справочник публичного API
new LabColors()
Создаёт движок без словаря ролей: до loadConfig любой вызов resolveTheme отклоняется ошибкой config_required. Повторные одинаковые вызовы resolveTheme обслуживаются из внутреннего кэша (кэш-пространство привязано к отпечатку конфига).
engine.resolveTheme(bgHex, theme): ResolvedTheme
bgHex— фон в формате#RGBили#RRGGBB.theme—"light" | "dark" | "light-ic" | "dark-ic".
Возвращает объект ResolvedTheme:
interface ResolvedTheme {
theme: ThemeName;
background: string; // нормализованный #RRGGBB
vars: Record<string, string>; // достижимые роли: "--lab-<ключ>" → готовое CSS-значение oklch
roles: Record<string, RoleResult>; // все роли с деталями
}
type RoleResult =
| SolvedColor
| TranslucentRole
| GlowRole
| MaterialRole
| NoneRole
| UnreachableRole;Каждая роль — одна из шести категорий результата; у Glow есть два терминальных состояния:
SolvedColor— цвет найден (kind: "color", поляcss— готовоеoklch(L% C H),hex— тот же цвет как данные,lc,wcagRatio, …).TranslucentRole— полупрозрачная роль лестницы или альфа-аналога (kind: "translucent"):css— готовоеoklch(L% C H / A),tintHex— тинт как данные,alpha, плюсcompositeHex/compositeLc/compositeWcag— exact encoded-sRGB8 reference-композит на фоне резолва и его контраст. Конкретный renderer и color-management pipeline проверяются отдельно.GlowRole— discriminated union.kind: "glow"несёт два цвета дляmix-blend-mode: screen:cssVar— halo,${cssVar}-core— core,${cssVar}-alpha— каноническаяalphaCss.compositeProfile/compositeGuaranteeописывают exact point-композит отдельно отdecisionGuarantee.layerRecipeProfileфиксирует общий рецепт слоёв"cam16-jprime-oklab-cusp-v1", а обязательныйappearanceDiagnosticProfile— CAM16 appearance-модель результата. ОтдельныйselectionDiagnosticProfileсообщает, применялась ли CAM16-диагностика именно при выборе состояния: для exact no-op он равенnull, для legacy solve —"cam16-ucs-jprime-li2017-v1".targetStatusразличает эти ветви явно:"exact-noop-unreachable","legacy-reached"или"legacy-unreachable". Determinate-форма — union из этих трёх согласованных ветвей: смешать stable/legacy profile, guarantee, selection diagnostic, status иdegradedна уровне TypeScript невозможно. Outward interval не входит в Glow capability этого релиза.kind: "glow-indeterminate"означает, что профильstable-v1не выбрал target/max state без sound bound; для такой роли CSS-переменные не эмитятся и legacy fallback не применяется. Цель и все*CompositeHex/*AchievedDjотносятся только к изолированным point-слоям, а не к полному blur/overlap-эффекту, браузеру или дисплею.MaterialRole— union трёх терминальных исходов: satisfied transparent endpoint (alpha = 0), satisfied bisection bracket (alphaпобитно равнаupperAlpha) и degraded opaque endpoint (alpha = 1).alphaStatus,alphaGuaranteeи compatibility-полеguaranteedсогласованы типом.floor— запрошенный пол; он держится только приalphaStatus: "satisfied". Primary остаётся солид-каноном,-01— тинтом,-02— опаковой базой. Численный профиль явно фиксирует operation order:encoded-srgb-byte-scale-affine-platform-binary64-powf-v1: original WCAG 2.1 (2018) split0.03928, conservative binary64 channel envelope и обе стороны пересечённого EOTF seam. Bracket доступен только после directed-search guard; это platform-characterization, а не двухугловая теорема, глобальная монотонность, первый passing state или точная минимальная alpha.NoneRole— роль намеренно пустая по дизайну (kind: "none"), не ошибка.UnreachableRole— ни один цвет не удовлетворяет требованиям для этого фона (kind: "unreachable").
Недостижимость отдельных ролей — часть успешного результата. Отклоняет весь вызов (как Error) только при невалидном аргументе:
| Код ошибки | Причина |
|------------|---------|
| config_required | конфиг ещё не загружен (loadConfig не вызывался) |
| invalid_background | bgHex не является #RGB или #RRGGBB |
| unknown_theme | theme не входит в список допустимых |
engine.loadConfig(json): string
Загружает конфиг дизайн-системы (JSON по типу ThemeConfig; схема — в репозитории: docs/decisions/0001-config-boundary.md, TS-типы — в поставляемом labcolors.d.ts). Это единственный источник словаря ролей — встроенной таблицы в движке нет: до загрузки конфига resolveTheme отклоняется ошибкой config_required. Полный preflight проверяет не только имена ролей, но и итоговый CSS-namespace: -core/-alpha Glow и -01/-02 Material не могут быть затёрты другой ролью или алиасом. Невалидный конфиг отклоняется структурной ошибкой invalid_config: … и НЕ меняет состояние. Возвращает отпечаток конфига — 16 hex-символов; разные конфиги дают разные отпечатки и разные кэш-пространства.
Каждый Glow-рецепт обязан явно выбрать numerical-decision profile; default и неявного legacy-пути нет:
{
"kind": "glow",
"source": { "kind": "brand" },
"step": "base",
"decision_profile": "stable-v1"
}stable-v1 возвращает typed glow-indeterminate, когда sound bound для
нетривиального CAM16 target/max-решения недоступен. Если существующая интеграция
обязана пока сохранить прежнюю CSS-эмиссию, укажите
legacy-platform-dependent-v1 явно и обрабатывайте его как compatibility
profile, а не как численную гарантию. Полная миграция с rollback-порядком — в
гайде exact alpha / typed Glow.
engine.recheckContrast(bgHex, fgHexes, theme): Float64Array
Дешёвая покадровая проверка: какие контрасты дают цвета fgHexes на фоне bgHex под темой theme, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Возвращает Float64Array пар [lc, wcagRatio] в порядке fgHexes: индекс 2·i — знаковый Lc цвета i, 2·i+1 — его WCAG-отношение. Это примитив, которым adaptTheme решает, пора ли пересчитывать.
engine.recheckContrastMulti(bgHexes, fgHexes, theme): Float64Array
Батч-вариант recheckContrast для меняющегося фона (градиент / картинка / bg-blur / стекло): проверяет один набор fgHexes сразу против НЕСКОЛЬКИХ сэмплов фона bgHexes за один вызов, разделяя прямой ход модели каждого переднего плана между всеми сэмплами (он от фона не зависит). Байт-в-байт равен N отдельным вызовам recheckContrast, пара за парой — это закреплено parity-тестом границы, — только быстрее: ~2.5× на 3 сэмплах. Возвращает плоский background-major Float64Array: сэмпл s, цвет i лежит в (s · fgHexes.length + i) · 2 (lc) и +1 (wcagRatio). Именно этим вызовом adaptTheme схлопывает worst-case цикл по сэмплам в одно обращение к движку; на одном сэмпле выигрыша нет — контроллер остаётся на recheckContrast.
engine.muddiness(hex): number
muddiness возвращает замороженный числовой выход исторической формулы. Это
experimental compatibility proxy: legacy-имя сохранено для совместимости, но
значение не является валидированным на наблюдателях человеческим вердиктом
clean/dirty и не должно использоваться как production decision. Сам диапазон
[0, 1] — свойство формулы, а не шкала человеческого восприятия.
applyTheme(element, result): void
Записывает все достижимые роли из result.vars в element.style через setProperty. Устаревшие --lab-* от предыдущего вызова сбрасываются перед записью — роль, потерявшая достижимость, не остаётся висеть.
watchTheme(element, options): WatchController
Синхронизирует --lab-* переменные с фактическим фоном элемента. Применяет немедленно при создании и возвращает контроллер.
interface WatchThemeOptions {
colors: LabColors;
theme: ThemeName;
background?: string | (() => string); // явный фон (если автоматический невозможен)
target?: HTMLElement; // куда писать переменные (по умолчанию: element)
fallback?: string; // фон при полностью прозрачной цепочке (по умолчанию "#FFFFFF")
observe?: boolean; // авто-обновление при DOM-мутациях (по умолчанию true)
root?: Node; // корень MutationObserver (по умолчанию: documentElement)
}
interface WatchController {
refresh(force?: boolean): ResolvedTheme | null; // пересчитать и применить, если фон изменился
setTheme(theme: ThemeName): void; // переключить тему и применить
background(): string; // последний вычисленный фон (hex)
stop(): void; // отключить наблюдателей
}Для поверхности над изображением, градиентом или размытым фоном — где автоматическое вычисление невозможно — передайте явный background (hex-строку или функцию, возвращающую hex).
adaptTheme(element, options): AdaptController
Режим с dwell-фильтром: дешёвая проверка каждый кадр, пересчёт и плавный переход только при устойчивом ухудшении контраста. Это один порог с выдержкой, не двухпороговый гистерезис. Применяет немедленно и возвращает контроллер.
interface AdaptThemeOptions {
colors: LabColors;
theme: ThemeName;
background?: string | string[] | (() => string | string[]);
// один hex или несколько образцов фона (наихудший учитывается)
target?: HTMLElement; // куда писать переменные (по умолчанию: element)
fallback?: string; // фон при прозрачной цепочке (по умолчанию "#FFFFFF")
dropFraction?: number; // запас контраста до пересчёта (по умолчанию 0.2)
sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120)
dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250)
easeMs?: number; // длительность перехода (по умолчанию 280; уменьшается при reduced-motion)
strict?: boolean; // legacy characterized clamp; не universal floor certificate (по умолчанию false)
reducedMotion?: boolean; // переопределить системную настройку
}
interface AdaptController {
tick(now?: number): void; // один шаг (или использовать start())
setTheme(theme: ThemeName): void; // переключить тему мгновенно
start(): void; // запустить внутренний requestAnimationFrame-цикл
stop(): void; // остановить цикл
current(): Record<string, string>; // текущие применённые --lab-* переменные
}strict сохраняет прежнее runtime-поведение, но не является доказательством
минимального или проходящего состояния на каждом кадре: путь
Oklab→gamut clip→sRGB8 немонотонен. Finite hard-floor-safe replacement ведётся в
#287; до него этот флаг следует считать compatibility characterization.
Управляйте через start() (внутренний rAF-цикл) или вызывайте tick() из собственного цикла. Смена темы применяется мгновенно — это осознанное намерение, а не дрейф.
Перед применением результата контроллер проверяет provenance как stable-, так и
legacy-ветвей Glow. Нетипизированный внешний mock с невозможным сочетанием
profile/guarantee/diagnostic/status отклоняется TypeError; fallback-цвет из
такого объекта не применяется.
effectiveBackground(element, options?): string
Возвращает непрозрачный reference-#RRGGBB, вычисленный для поддерживаемого
подмножества DOM background-color. Это не browser pixel capture и не
сертификат цвета, который реально видит наблюдатель. Helper обходит цепочку
предков и композитит распознанные слои поверх fallback (по умолчанию белый).
const bg = effectiveBackground(panel); // например "#0F1014"
const bg2 = effectiveBackground(panel, { fallback: "#101012" });Честное ограничение: работает только с поддерживаемыми сплошными и
полупрозрачными background-color; неподдерживаемый CSS, неполная прозрачная
цепочка, background-image, градиент, blur и video не дают полного наблюдения.
Текущий compatibility helper ещё может отбросить неподдерживаемый слой или
использовать fallback; для correctness передайте фон явно. Typed Unknown
replacement принадлежит #283.
Дополнительно экспортируются вспомогательные функции для работы со слоями: parseCssColor, compositeOver, compositeStackToHex, toHex и oklabLerp (перцептуально равномерная интерполяция между двумя hex-значениями — траектория цвета при переходе).
Размер бандла
Размер не закреплён в документации приблизительным числом: оно устаревает при
любом изменении солвера. SSOT — шаг CI report bundle size (gzip) в ci.yml,
который для каждого коммита печатает точные raw-байты и результат gzip -9
отдельно для labcolors_bg.wasm и wasm-bindgen-обёртки labcolors.js.
Это весь движок: CAM16, солверы контраста, лестницы и граница конфига. .wasm
поставляется отдельным ассетом. Будет ли его загрузка критическим путём первого
рендера, определяет интеграция: до первого resolveTheme инициализация обязана
завершиться; приложение может preload/кэшировать модуль. JS-хелперы
(applyTheme, watchTheme, adaptTheme, effectiveBackground) имеют
именованные экспорты и допускают tree-shaking, но их размер также следует мерить
сборкой, а не описывать приблизительно.
Release packer закреплён на Node 24.14/npm 11.9; это не consumer floor.
Публичный контракт Node >=22.11.0 независимо прогоняется отдельным CI job. Browser-
матрица использует прямую передачу wasm-байтов в init({ module_or_path }) и
headless Chrome из CI. ESM/URL-обёртка генерируется wasm-pack, но совместимость
с конкретной версией Vite, webpack, Next или другим браузером не заявляется без
отдельного smoke-гейта.
Для аудиторов цепочки поставки
- Build metadata: экспорт
@labpics/colors/build-metadata.jsonдекларирует source SHA и conformance pack для установленного WASM; verifier отклоняет любое лишнее, отсутствующее или несовпадающее поле. Объект не подписан и не заменяет отключённую npm/Sigstore provenance-аттестацию. - Network access (Socket и др.): единственный
fetchв пакете (pkg/labcolors.js) загружает СОБСТВЕННЫЙ.wasm-файл пакета приinit(url)— стандартный лоадер wasm-bindgen. Ни внешних адресов, ни отправки данных, ни исполнения при импорте. В node-пути (передача байтов)fetchне вызывается. - Bundlephobia
BuildError: их webpack-конвейер не умеет.wasm-ассеты («loader customization needed») — так падает почти любой WASM-пакет. Реальный размер конкретного коммита показывает CI-шагreport bundle size (gzip). - Zero runtime JS-dependencies: npm-поле
dependenciesпусто — транзитивной JS/npm-цепочки поставки нет. Rust-крейты сборки (serde,serde_jsonи др.) компилируются ВНУТРЬ.wasm(учтены CI-замером); их цепочка аудируется на стороне сборки —cargo audit(RustSec) в CI lab-colors.
