@labpics/colors 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -11
- package/adapt-theme.js +43 -16
- package/effective-bg.d.ts +6 -4
- package/effective-bg.js +117 -15
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/pkg/labcolors.d.ts +78 -25
- package/pkg/labcolors.js +15 -40
- package/pkg/labcolors_bg.wasm +0 -0
- package/pkg/labcolors_bg.wasm.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @labpics/colors
|
|
2
2
|
|
|
3
|
-
Адаптивные цветовые роли для дизайн-системы. Получает фоновый цвет и тему — возвращает полный набор ролей (`--lab-label-primary`, `--lab-icon`, `--lab-border-base`, …)
|
|
3
|
+
Адаптивные цветовые роли для дизайн-системы. Получает фоновый цвет и тему — возвращает полный набор ролей (`--lab-label-primary`, `--lab-icon`, `--lab-border-base`, …). CSS-переменные несут готовое значение `oklch(L% C H)` (для полупрозрачных ролей — `oklch(L% C H / A)`); сырой `#RRGGBB` остаётся данными роли (`roles.<ключ>.hex`). Ядро написано на Rust и скомпилировано в WebAssembly; пакет не имеет runtime-зависимостей.
|
|
4
4
|
|
|
5
5
|
Ядро возвращает **данные**, не затрагивает DOM. Три вспомогательные функции переводят эти данные в живые CSS-переменные: `applyTheme` (разовое применение), `watchTheme` (реактивное — обновляется при изменении фона) и `adaptTheme` (плавная адаптация для фона, меняющегося каждый кадр).
|
|
6
6
|
|
|
@@ -33,8 +33,9 @@ await init(); // загрузить WASM-модуль (о
|
|
|
33
33
|
const engine = new LabColors(); // движок по умолчанию
|
|
34
34
|
|
|
35
35
|
const result = engine.resolveTheme("#FFFFFF", "light");
|
|
36
|
-
// result.vars → { "--lab-label-primary": "
|
|
37
|
-
//
|
|
36
|
+
// result.vars → { "--lab-label-primary": "oklch(14.79140% 0.012878 284.717)",
|
|
37
|
+
// "--lab-icon": "oklch(66.97448% 0.014606 285.999)", ... }
|
|
38
|
+
// result.roles → детали каждой роли (css, hex, контраст, флаги)
|
|
38
39
|
|
|
39
40
|
applyTheme(document.documentElement, result); // записать все --lab-* в элемент
|
|
40
41
|
```
|
|
@@ -128,16 +129,17 @@ adaptTheme(hero, {
|
|
|
128
129
|
interface ResolvedTheme {
|
|
129
130
|
theme: ThemeName;
|
|
130
131
|
background: string; // нормализованный #RRGGBB
|
|
131
|
-
vars: Record<string, string>; // достижимые роли: "--lab-<ключ>" →
|
|
132
|
+
vars: Record<string, string>; // достижимые роли: "--lab-<ключ>" → готовое CSS-значение oklch
|
|
132
133
|
roles: Record<string, RoleResult>; // все роли с деталями
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
type RoleResult = SolvedColor | NoneRole | UnreachableRole;
|
|
136
|
+
type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole;
|
|
136
137
|
```
|
|
137
138
|
|
|
138
|
-
Каждая роль — одно из
|
|
139
|
+
Каждая роль — одно из четырёх состояний:
|
|
139
140
|
|
|
140
|
-
- `SolvedColor` — цвет найден (`kind: "color"`, поля `
|
|
141
|
+
- `SolvedColor` — цвет найден (`kind: "color"`, поля `css` — готовое `oklch(L% C H)`, `hex` — тот же цвет как данные, `lc`, `wcagRatio`, …).
|
|
142
|
+
- `TranslucentRole` — полупрозрачная роль лестницы или альфа-аналога (`kind: "translucent"`): `css` — готовое `oklch(L% C H / A)`, `tintHex` — тинт как данные, `alpha`, плюс `compositeHex` / `compositeLc` / `compositeWcag` — солид-композит на фоне резолва и его контраст (браузер композитит тинт на живой подложке).
|
|
141
143
|
- `NoneRole` — роль намеренно пустая по дизайну (`kind: "none"`), не ошибка.
|
|
142
144
|
- `UnreachableRole` — ни один цвет не удовлетворяет требованиям для этого фона (`kind: "unreachable"`).
|
|
143
145
|
|
|
@@ -150,6 +152,24 @@ type RoleResult = SolvedColor | NoneRole | UnreachableRole;
|
|
|
150
152
|
|
|
151
153
|
---
|
|
152
154
|
|
|
155
|
+
### `engine.loadConfig(json): string`
|
|
156
|
+
|
|
157
|
+
Загружает конфиг дизайн-системы (JSON по типу `ThemeConfig`) — главная фича конфиг-границы: движок начинает эмитить роли и подтон конфига вместо встроенной таблицы. Полный preflight: невалидный конфиг отклоняется структурной ошибкой `invalid_config: …` и НЕ меняет состояние. Возвращает отпечаток конфига — 16 hex-символов; разные конфиги дают разные отпечатки и разные кэш-пространства.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### `engine.recheckContrast(bgHex, fgHexes, theme): Float64Array`
|
|
162
|
+
|
|
163
|
+
Дешёвая покадровая проверка: какие контрасты дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковый `Lc` цвета `i`, `2·i+1` — его WCAG-отношение. Это примитив, которым `adaptTheme` решает, пора ли пересчитывать.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### `engine.muddiness(hex): number` · `engine.confidence(hex): number`
|
|
168
|
+
|
|
169
|
+
`muddiness` — оценка «грязи» цвета `hex` в диапазоне `[0, 1]` (Закон Грязи). `confidence` — надёжность этой оценки: `0` означает, что оценке нельзя доверять (у границы решения или серого фронтира), выше — увереннее. Верхний потолок — деталь калибровки, не контракт: не хардкодить.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
153
173
|
### `applyTheme(element, result): void`
|
|
154
174
|
|
|
155
175
|
Записывает все достижимые роли из `result.vars` в `element.style` через `setProperty`. Устаревшие `--lab-*` от предыдущего вызова сбрасываются перед записью — роль, потерявшая достижимость, не остаётся висеть.
|
|
@@ -168,6 +188,7 @@ interface WatchThemeOptions {
|
|
|
168
188
|
target?: HTMLElement; // куда писать переменные (по умолчанию: element)
|
|
169
189
|
fallback?: string; // фон при полностью прозрачной цепочке (по умолчанию "#FFFFFF")
|
|
170
190
|
observe?: boolean; // авто-обновление при DOM-мутациях (по умолчанию true)
|
|
191
|
+
root?: Node; // корень MutationObserver (по умолчанию: documentElement)
|
|
171
192
|
}
|
|
172
193
|
|
|
173
194
|
interface WatchController {
|
|
@@ -232,10 +253,12 @@ const bg2 = effectiveBackground(panel, { fallback: "#101012" });
|
|
|
232
253
|
|
|
233
254
|
## Размер бандла
|
|
234
255
|
|
|
235
|
-
| Артефакт | raw | gzip |
|
|
236
|
-
|
|
237
|
-
| `labcolors_bg.wasm` | ~313 КБ | ~138 КБ |
|
|
238
|
-
| `labcolors.js` (JS-обёртка) | ~17 КБ | ~4 КБ |
|
|
256
|
+
| Артефакт | raw | gzip |
|
|
257
|
+
|----------|-----|------|
|
|
258
|
+
| `labcolors_bg.wasm` | ~313 КБ | ~138 КБ |
|
|
259
|
+
| `labcolors.js` (JS-обёртка) | ~17 КБ | ~4 КБ |
|
|
260
|
+
|
|
261
|
+
Замер CI-шага `report bundle size` (ci.yml) — raw и gzip.
|
|
239
262
|
|
|
240
263
|
Это ВЕСЬ движок: перцептивная модель CAM16, солверы контраста, лестницы, граница конфига. `.wasm` — не JS-байты бандла, а ассет: он **не на критическом пути рендера** — грузится параллельно, компилируется потоково вне главного треда, кэшируется браузером после первой загрузки. Вспомогательные функции (`applyTheme`, `watchTheme`, `adaptTheme`, `effectiveBackground`) — несколько сотен байт чистого JavaScript с tree-shaking через именованные экспорты.
|
|
241
264
|
|
package/adapt-theme.js
CHANGED
|
@@ -150,6 +150,12 @@ export function adaptTheme(element, options) {
|
|
|
150
150
|
let theme = options.theme;
|
|
151
151
|
/** @type {{ cssVar: string, key: string, lc: number, hex: string, legalFloor: number|null }[]} stable role order */
|
|
152
152
|
let roles = [];
|
|
153
|
+
/** Full canonical var set from the last solve — the `result.vars` of EVERY
|
|
154
|
+
* reachable role (color AND translucent), each an oklch string. Every apply is
|
|
155
|
+
* `applyTheme(target, {...baseVars, ...easedColorOverlay})`, so translucent
|
|
156
|
+
* roles are never dropped by `applyTheme`'s clear-then-write and always carry
|
|
157
|
+
* the current theme's value. Only `kind === "color"` roles (in `roles`) ease. */
|
|
158
|
+
let baseVars = {};
|
|
153
159
|
/** @type {Map<string,{from:string,to:string}>} in-flight ease per cssVar */
|
|
154
160
|
let easing = new Map();
|
|
155
161
|
let easeStart = 0;
|
|
@@ -209,6 +215,9 @@ export function adaptTheme(element, options) {
|
|
|
209
215
|
// Resolve a fresh set and adopt it as the current colours (no ease).
|
|
210
216
|
const solveAndAdopt = (bg, now) => {
|
|
211
217
|
const result = colors.resolveTheme(bg, theme);
|
|
218
|
+
// Carry the FULL canonical var set (color + translucent) so no reachable
|
|
219
|
+
// role is dropped by a subsequent apply; only color roles feed the ease.
|
|
220
|
+
baseVars = result.vars && typeof result.vars === "object" ? result.vars : {};
|
|
212
221
|
roles = Object.entries(result.roles)
|
|
213
222
|
.filter(([, r]) => r && r.kind === "color")
|
|
214
223
|
.map(([key, r]) => ({
|
|
@@ -241,13 +250,15 @@ export function adaptTheme(element, options) {
|
|
|
241
250
|
}
|
|
242
251
|
};
|
|
243
252
|
|
|
244
|
-
|
|
253
|
+
// Every write goes through the full canonical set with the (optional) eased
|
|
254
|
+
// color overlay on top — so translucent roles in `baseVars` persist through
|
|
255
|
+
// `applyTheme`'s clear-then-write, and non-eased color roles keep their
|
|
256
|
+
// canonical oklch form. `overlay` carries only in-flight color roles as hex.
|
|
257
|
+
const applyHexes = (overlay) => applyTheme(target, { vars: { ...baseVars, ...overlay } });
|
|
245
258
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
applyHexes(vars);
|
|
250
|
-
};
|
|
259
|
+
// Apply the canonical set as-is (no ease in flight): color roles show their
|
|
260
|
+
// oklch form, translucent roles their tint+alpha.
|
|
261
|
+
const applyRolesDirect = () => applyHexes({});
|
|
251
262
|
|
|
252
263
|
// Begin an ease from the currently-applied colours toward the role colours.
|
|
253
264
|
// `held` latches the per-role displayed blend so it only ever advances toward
|
|
@@ -289,15 +300,29 @@ export function adaptTheme(element, options) {
|
|
|
289
300
|
|
|
290
301
|
const stepEase = (now, samples) => {
|
|
291
302
|
const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
|
|
303
|
+
// Terminate the ease when it is done (`t >= 1`) OR when the clock went
|
|
304
|
+
// non-finite (a NaN/±∞ `now` making `t` non-finite): drop the segments and
|
|
305
|
+
// re-apply the canonical set, so the just-eased color roles snap back from
|
|
306
|
+
// their interpolated hex to their oklch form (translucent roles stay put).
|
|
307
|
+
// The non-finite guard is load-bearing for STATE, not just paint: without it
|
|
308
|
+
// a persistently bad clock would leave `easing` in flight forever, freezing
|
|
309
|
+
// color roles at their hex destination and never reverting to canonical
|
|
310
|
+
// oklch. (`easeOut` separately guards the interpolation math from
|
|
311
|
+
// `#NANNANNAN`; this guards the controller's easing state.)
|
|
312
|
+
if (t >= 1 || !Number.isFinite(t)) {
|
|
313
|
+
easing = new Map();
|
|
314
|
+
applyRolesDirect();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
292
317
|
const e = easeOut(t);
|
|
293
318
|
const bgLums = strict ? samples.map(relativeLuminanceHex) : null;
|
|
294
|
-
|
|
319
|
+
// Overlay carries ONLY in-flight color roles (as interpolated hex); every
|
|
320
|
+
// other role — non-eased color and all translucent — keeps its canonical
|
|
321
|
+
// `baseVars` value under the merge in `applyHexes`.
|
|
322
|
+
const overlay = {};
|
|
295
323
|
for (const r of roles) {
|
|
296
324
|
const seg = easing.get(r.cssVar);
|
|
297
|
-
if (!seg)
|
|
298
|
-
vars[r.cssVar] = r.hex;
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
325
|
+
if (!seg) continue;
|
|
301
326
|
let blend = e;
|
|
302
327
|
if (strict && r.legalFloor != null) {
|
|
303
328
|
// Hold the floor (against the worst sample), then LATCH: the displayed
|
|
@@ -311,17 +336,19 @@ export function adaptTheme(element, options) {
|
|
|
311
336
|
blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held);
|
|
312
337
|
seg.held = blend;
|
|
313
338
|
}
|
|
314
|
-
|
|
339
|
+
overlay[r.cssVar] = lerpHex(seg.from, seg.to, blend);
|
|
315
340
|
}
|
|
316
|
-
applyHexes(
|
|
317
|
-
if (t >= 1) easing = new Map();
|
|
341
|
+
applyHexes(overlay);
|
|
318
342
|
};
|
|
319
343
|
|
|
344
|
+
// The full applied picture: the canonical set (color + translucent) with each
|
|
345
|
+
// in-flight color role reported at its LOGICAL target (`seg.to`), so a reader
|
|
346
|
+
// sees where the ease is going, not a mid-transition frame.
|
|
320
347
|
const currentApplied = () => {
|
|
321
|
-
const vars = {};
|
|
348
|
+
const vars = { ...baseVars };
|
|
322
349
|
for (const r of roles) {
|
|
323
350
|
const seg = easing.get(r.cssVar);
|
|
324
|
-
vars[r.cssVar] = seg
|
|
351
|
+
if (seg) vars[r.cssVar] = seg.to;
|
|
325
352
|
}
|
|
326
353
|
return vars;
|
|
327
354
|
};
|
package/effective-bg.d.ts
CHANGED
|
@@ -29,11 +29,13 @@ export declare function compositeOver(top: Rgba, bottom: Rgba): Rgba;
|
|
|
29
29
|
export declare function toHex(rgb: Rgba | [number, number, number]): string;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Interpolate two
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Interpolate two colours in Oklab at `t ∈ [0,1]` → `#RRGGBB`. `from`/`to` may be
|
|
33
|
+
* any string `parseCssColor` accepts (`#hex`, `rgb()`/`rgba()`, `oklch()`,
|
|
34
|
+
* `transparent`), not only `#RRGGBB`. Perceptually uniform (even crossfade timing,
|
|
35
|
+
* non-muddy chroma path); endpoints are exact and out-of-gamut intermediates are
|
|
36
|
+
* clamped per channel.
|
|
35
37
|
*/
|
|
36
|
-
export declare function oklabLerp(
|
|
38
|
+
export declare function oklabLerp(from: string, to: string, t: number): string;
|
|
37
39
|
|
|
38
40
|
/** Composite an ordered front-to-back layer stack over an opaque base → `#RRGGBB`. */
|
|
39
41
|
export declare function compositeStackToHex(layersFrontToBack: Rgba[], opaqueBase: Rgba): string;
|
package/effective-bg.js
CHANGED
|
@@ -18,15 +18,36 @@
|
|
|
18
18
|
// `background` option of `watchTheme`, or a sampled average). What it does cover —
|
|
19
19
|
// translucent panels over solid parents — is the common case and is composited
|
|
20
20
|
// *correctly* (true source-over alpha), not approximated.
|
|
21
|
+
//
|
|
22
|
+
// COLOUR FORMS: `parseCssColor` reads the forms this package actually meets —
|
|
23
|
+
// `#hex`, `rgb()/rgba()` (legacy comma and modern space/slash), `transparent`,
|
|
24
|
+
// and `oklch()` (the engine's OWN emission form since 0.4.0, and what a browser
|
|
25
|
+
// serialises `background-color` back to for an oklch-painted surface). Other
|
|
26
|
+
// modern forms — `lab()`, `lch()`, `color(srgb …)`, `color-mix()`, `hsl()`,
|
|
27
|
+
// named colours beyond `transparent` — are NOT parsed and return `null` (a
|
|
28
|
+
// dropped layer). If a surface's background is authored in one of those, pass the
|
|
29
|
+
// effective background explicitly via the `background` option.
|
|
21
30
|
|
|
22
31
|
/** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */
|
|
23
32
|
|
|
24
33
|
/**
|
|
25
34
|
* Parse a CSS colour string into `[r, g, b, a]`, or `null` if unrecognised.
|
|
26
35
|
*
|
|
27
|
-
* Handles the forms computed style actually yields
|
|
28
|
-
* `rgba(r, g, b, a)
|
|
29
|
-
* `
|
|
36
|
+
* Handles the forms computed style actually yields:
|
|
37
|
+
* - `rgb(r, g, b)` / `rgba(r, g, b, a)` and the modern `rgb(r g b / a)`;
|
|
38
|
+
* - `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa`;
|
|
39
|
+
* - the `transparent` keyword;
|
|
40
|
+
* - `oklch(L C H)` / `oklch(L C H / A)` — the engine's own emission form and
|
|
41
|
+
* what a browser serialises an oklch-painted `background-color` back to.
|
|
42
|
+
* `L` accepts both a `0..1` number (Chrome's computed form) and a percentage
|
|
43
|
+
* (the engine's literal form); `C` a number or a `%` (100% = 0.4 per CSS
|
|
44
|
+
* Color 4); `H` degrees (bare or `deg`-suffixed); a missing component
|
|
45
|
+
* (`none`) is 0. Conversion to sRGB bytes reuses the file's Oklab↔sRGB
|
|
46
|
+
* transform and is byte-exact to the core's round-trip proof.
|
|
47
|
+
*
|
|
48
|
+
* Any other form — `lab()`, `lch()`, `color(srgb …)`, `color-mix()`, `hsl()`,
|
|
49
|
+
* named colours other than `transparent` — returns `null` (treated as "no
|
|
50
|
+
* layer"); supply such backgrounds explicitly via the `background` option.
|
|
30
51
|
*
|
|
31
52
|
* @param {string} css
|
|
32
53
|
* @returns {Rgba | null}
|
|
@@ -36,6 +57,8 @@ export function parseCssColor(css) {
|
|
|
36
57
|
const s = css.trim().toLowerCase();
|
|
37
58
|
if (s === "transparent") return [0, 0, 0, 0];
|
|
38
59
|
|
|
60
|
+
if (s.startsWith("oklch(") && s.endsWith(")")) return parseOklch(s.slice(6, -1));
|
|
61
|
+
|
|
39
62
|
if (s[0] === "#") {
|
|
40
63
|
const h = s.slice(1);
|
|
41
64
|
if (h.length === 3 || h.length === 4) {
|
|
@@ -73,6 +96,83 @@ function clamp255(v) {
|
|
|
73
96
|
return Math.min(255, Math.max(0, v));
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Parse the inside of an `oklch(...)` (the part between the parens) into sRGB
|
|
101
|
+
* `[r, g, b, a]`, or `null` on any malformed component.
|
|
102
|
+
*
|
|
103
|
+
* The engine emits `oklch(L% C H)` / `oklch(L% C H / A)`; a browser's computed
|
|
104
|
+
* form is `oklch(<L 0..1> C H [/ A])`. Components are whitespace-separated with
|
|
105
|
+
* an optional `/`-separated alpha, exactly like `rgb()`'s modern syntax.
|
|
106
|
+
* oklch → Oklab is `a = C·cos(H)`, `b = C·sin(H)`; then the file's own
|
|
107
|
+
* `oklabToLinearRgb` + `linearToSrgb` land it in sRGB — the SAME transform (and
|
|
108
|
+
* the SAME clamp-then-round the core uses in `hex_from_srgb`) that carries the
|
|
109
|
+
* emitter's byte-exact round-trip, so an emitted string decodes to its source
|
|
110
|
+
* bytes. Out-of-gamut channels clamp per channel, matching `oklabLerp`/`toHex`.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} inner the text between `oklch(` and `)`
|
|
113
|
+
* @returns {Rgba | null}
|
|
114
|
+
*/
|
|
115
|
+
function parseOklch(inner) {
|
|
116
|
+
const slash = inner.indexOf("/");
|
|
117
|
+
const lch = (slash >= 0 ? inner.slice(0, slash) : inner).trim();
|
|
118
|
+
const alphaTok = slash >= 0 ? inner.slice(slash + 1).trim() : null;
|
|
119
|
+
const comps = lch.split(/\s+/).filter((p) => p.length > 0);
|
|
120
|
+
if (comps.length !== 3) return null;
|
|
121
|
+
|
|
122
|
+
const L = oklchLightness(comps[0]);
|
|
123
|
+
const C = oklchChroma(comps[1]);
|
|
124
|
+
const H = oklchHue(comps[2]);
|
|
125
|
+
const a = alphaTok === null ? 1 : oklchAlpha(alphaTok);
|
|
126
|
+
if (L === null || C === null || H === null || a === null) return null;
|
|
127
|
+
|
|
128
|
+
const hRad = (H * Math.PI) / 180;
|
|
129
|
+
const lin = oklabToLinearRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
|
130
|
+
const byte = (i) => Math.round(clamp255(linearToSrgb(lin[i]) * 255));
|
|
131
|
+
return [byte(0), byte(1), byte(2), a];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Strict CSS `<number>` (no trailing junk, unlike `parseFloat`), else `null`. */
|
|
135
|
+
function cssNumber(tok) {
|
|
136
|
+
return /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(tok) ? parseFloat(tok) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** L: a percentage → `/100` into `0..1`; a bare number is already `0..1`; `none`
|
|
140
|
+
* → 0. Lightness clamps to `[0, 1]` (CSS Color 4) — a byte-level clamp alone
|
|
141
|
+
* masks out-of-range L only at low chroma, not high, so clamp L explicitly. */
|
|
142
|
+
function oklchLightness(tok) {
|
|
143
|
+
if (tok === "none") return 0;
|
|
144
|
+
const pct = tok.endsWith("%");
|
|
145
|
+
const n = cssNumber(pct ? tok.slice(0, -1) : tok);
|
|
146
|
+
if (n === null) return null;
|
|
147
|
+
return Math.min(1, Math.max(0, pct ? n / 100 : n));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** C: a bare number is absolute chroma; a percentage is a fraction of 0.4
|
|
151
|
+
* (CSS Color 4: 100% = 0.4); `none` → 0. Negative chroma clamps to 0. */
|
|
152
|
+
function oklchChroma(tok) {
|
|
153
|
+
if (tok === "none") return 0;
|
|
154
|
+
const pct = tok.endsWith("%");
|
|
155
|
+
const n = cssNumber(pct ? tok.slice(0, -1) : tok);
|
|
156
|
+
if (n === null) return null;
|
|
157
|
+
return Math.max(0, pct ? (n / 100) * 0.4 : n);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** H: degrees, bare or `deg`-suffixed; `none` → 0. (grad/rad/turn are out of
|
|
161
|
+
* scope — the engine and browsers emit bare degrees.) */
|
|
162
|
+
function oklchHue(tok) {
|
|
163
|
+
if (tok === "none") return 0;
|
|
164
|
+
return cssNumber(tok.endsWith("deg") ? tok.slice(0, -3) : tok);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Alpha: a number `0..1` or a percentage; `none` → 0; clamped to `[0, 1]`. */
|
|
168
|
+
function oklchAlpha(tok) {
|
|
169
|
+
if (tok === "none") return 0;
|
|
170
|
+
const pct = tok.endsWith("%");
|
|
171
|
+
const n = cssNumber(pct ? tok.slice(0, -1) : tok);
|
|
172
|
+
if (n === null) return null;
|
|
173
|
+
return Math.min(1, Math.max(0, pct ? n / 100 : n));
|
|
174
|
+
}
|
|
175
|
+
|
|
76
176
|
/**
|
|
77
177
|
* Source-over composite of `top` onto `bottom` (Porter-Duff "over").
|
|
78
178
|
*
|
|
@@ -156,23 +256,25 @@ function oklabToLinearRgb(L, A, B) {
|
|
|
156
256
|
}
|
|
157
257
|
|
|
158
258
|
/**
|
|
159
|
-
* Interpolate two
|
|
259
|
+
* Interpolate two colours in Oklab at `t ∈ [0,1]`, returning `#RRGGBB`.
|
|
160
260
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
261
|
+
* `from`/`to` may be ANY string `parseCssColor` accepts (`#rgb`/`#rrggbb`,
|
|
262
|
+
* `rgb()`/`rgba()`, `oklch()`, `transparent`) — not only `#RRGGBB`. Perceptually
|
|
263
|
+
* uniform: equal steps in `t` are equal steps in perceived lightness (and a
|
|
264
|
+
* straight, non-muddy path in hue/chroma), so a crossfade feels even rather than
|
|
265
|
+
* lingering bright. Endpoints are returned exactly (`t ≤ 0` → `from`, `t ≥ 1` →
|
|
266
|
+
* `to`), always normalised to `#RRGGBB` through `toHex`; out-of-gamut
|
|
165
267
|
* intermediates are clamped per channel. Unparseable input falls back to the
|
|
166
|
-
* nearer endpoint.
|
|
268
|
+
* nearer parseable endpoint.
|
|
167
269
|
*
|
|
168
|
-
* @param {string}
|
|
169
|
-
* @param {string}
|
|
270
|
+
* @param {string} from any colour string `parseCssColor` accepts
|
|
271
|
+
* @param {string} to any colour string `parseCssColor` accepts
|
|
170
272
|
* @param {number} t
|
|
171
|
-
* @returns {string}
|
|
273
|
+
* @returns {string} a `#RRGGBB` string
|
|
172
274
|
*/
|
|
173
|
-
export function oklabLerp(
|
|
174
|
-
const a = parseCssColor(
|
|
175
|
-
const b = parseCssColor(
|
|
275
|
+
export function oklabLerp(from, to, t) {
|
|
276
|
+
const a = parseCssColor(from);
|
|
277
|
+
const b = parseCssColor(to);
|
|
176
278
|
if (!a || !b) return (b && t >= 0.5) || !a ? (b ? toHex(b) : "#000000") : toHex(a);
|
|
177
279
|
if (t <= 0) return toHex(a);
|
|
178
280
|
if (t >= 1) return toHex(b);
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labpics/colors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Framework-agnostic contrast engine: resolve accessible, perceptually-anchored colour roles for any background. WASM core, zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/pkg/labcolors.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export interface SolvedColor {
|
|
|
19
19
|
readonly wcagRatio: number;
|
|
20
20
|
/** The legal floor squeezed this role onto the smallest step below its senior. */
|
|
21
21
|
readonly compressed: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* `true` when a coloured family label (M1) lost perceptible colour on its
|
|
24
|
+
* contract-solved lightness: the colour's M′ fell below the tint perceptibility
|
|
25
|
+
* floor, so at the family curve's extremes (near-white / near-black) the hue is
|
|
26
|
+
* physically indistinguishable. An honest, flagged outcome — not a silent
|
|
27
|
+
* degradation to grey. `false` for neutral labels and coloured labels that kept
|
|
28
|
+
* a distinguishable colour.
|
|
29
|
+
*/
|
|
30
|
+
readonly hueVanished: boolean;
|
|
31
|
+
/** Честный замер |ΔJ'| на отданном hex для dJ'-ролей; null у контраст-ролей (метрика — lc). */
|
|
32
|
+
readonly achievedDj: number | null;
|
|
22
33
|
/** The WCAG floor overrode the perceptual target. */
|
|
23
34
|
readonly floorOverride: boolean;
|
|
24
35
|
/**
|
|
@@ -60,11 +71,48 @@ export interface TranslucentRole {
|
|
|
60
71
|
readonly compositeLc: number;
|
|
61
72
|
/** WCAG 2.1 ratio of the composite. */
|
|
62
73
|
readonly compositeWcag: number;
|
|
74
|
+
/**
|
|
75
|
+
* `true` when the requested alpha was raised to the smallest resolvable value
|
|
76
|
+
* (α_min) because the requested transparency is not reproducible in gamut — an
|
|
77
|
+
* honest, flagged contract degradation (mirrors `SolvedColor.compressed` /
|
|
78
|
+
* `GlowRole.degraded`). The colour never lies: the composite still equals the
|
|
79
|
+
* target solid byte-for-byte; only `alpha` differs from what was asked. Always
|
|
80
|
+
* `false` for a direct ladder emission.
|
|
81
|
+
*/
|
|
82
|
+
readonly alphaCoerced: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* `true` when a solid family border (`border-<family>-strong`, M2) was darkened
|
|
85
|
+
* along the family curve to meet the AA UI floor (3:1), because the raw family
|
|
86
|
+
* tint did not clear it on this background — an honest, flagged minimal legal
|
|
87
|
+
* shift (family hue/chroma preserved, only lightness moved). `false` for a
|
|
88
|
+
* direct ladder emission and for legal family solids.
|
|
89
|
+
*/
|
|
90
|
+
readonly floorCoerced: boolean;
|
|
63
91
|
/** Ready-to-serve CSS value: "oklch(L% C H / A)". `vars` carries the same string. */
|
|
64
92
|
readonly css: string;
|
|
65
93
|
}
|
|
66
94
|
|
|
67
|
-
|
|
95
|
+
/** Свечение (kind glow, labui ADR-0002 §5): screen-слои + решённая интенсивность.
|
|
96
|
+
* Потребитель красит слои с mix-blend-mode: screen; `vars` несёт
|
|
97
|
+
* --lab-<role> (halo, oklch), --lab-<role>-core и --lab-<role>-alpha. */
|
|
98
|
+
export interface GlowRole {
|
|
99
|
+
readonly kind: "glow";
|
|
100
|
+
readonly cssVar: string;
|
|
101
|
+
/** Слой пересвета (малый радиус), #RRGGBB. */
|
|
102
|
+
readonly coreHex: string;
|
|
103
|
+
/** Слой ореола — источник, #RRGGBB. */
|
|
104
|
+
readonly haloHex: string;
|
|
105
|
+
/** Интенсивность screen-слоя, (0, 1]. */
|
|
106
|
+
readonly alpha: number;
|
|
107
|
+
/** Фактический |ΔJ'| композита от фона. */
|
|
108
|
+
readonly achievedDj: number;
|
|
109
|
+
/** Цель недостижима — ближайший достижимый шаг (ADR-0002, закон 2). */
|
|
110
|
+
readonly degraded: boolean;
|
|
111
|
+
/** Ready-to-serve CSS value халo: "oklch(L% C H)". */
|
|
112
|
+
readonly css: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type RoleResult = SolvedColor | TranslucentRole | GlowRole | NoneRole | UnreachableRole;
|
|
68
116
|
|
|
69
117
|
/** Пер-темная четвёрка якорных hex (light / dark / light-ic / dark-ic). */
|
|
70
118
|
export interface ThemeAnchors {
|
|
@@ -87,11 +135,23 @@ export type RoleRecipe =
|
|
|
87
135
|
| { kind: "dj-anchor"; light: number; dark: number }
|
|
88
136
|
| { kind: "decorative-lc"; magnitude: number }
|
|
89
137
|
| { kind: "ladder"; source: LadderSource; position: string }
|
|
138
|
+
| { kind: "glow"; source: LadderSource; step: "subtle" | "base" | "bloom" }
|
|
139
|
+
| { kind: "pair-fill"; source: LadderSource }
|
|
90
140
|
| { kind: "alpha-analog"; of: LadderSource; alpha: number }
|
|
91
141
|
| { kind: "zero" };
|
|
92
142
|
|
|
143
|
+
/** Именованный пресет ролей. Тонкий конфиг несёт `preset` вместо простыни `roles`. */
|
|
144
|
+
export type RolePreset = "labui";
|
|
145
|
+
|
|
93
146
|
/** Полный конфиг дизайн-системы клиента — вход loadConfig (JSON.stringify(config)). */
|
|
94
147
|
export interface ThemeConfig {
|
|
148
|
+
/**
|
|
149
|
+
* Пресет ролей: наполняет словарь дизайн-системы целиком, чтобы клиент вносил
|
|
150
|
+
* ТОЛЬКО значения (якоря, ручки), не семантику. Тонкий конфиг задаёт `preset` и
|
|
151
|
+
* ОПУСКАЕТ `roles`/`aliases`. Задать `preset` вместе с непустыми `roles` —
|
|
152
|
+
* ошибка `invalid_config` (оверрайд отдельных ролей — не этот слой).
|
|
153
|
+
*/
|
|
154
|
+
readonly preset?: RolePreset;
|
|
95
155
|
readonly brand: ThemeAnchors;
|
|
96
156
|
readonly neutral: {
|
|
97
157
|
readonly anchors: { light: string; mid: string; dark: string };
|
|
@@ -116,7 +176,8 @@ export interface ThemeConfig {
|
|
|
116
176
|
readonly chroma_fraction: number;
|
|
117
177
|
};
|
|
118
178
|
readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>;
|
|
119
|
-
|
|
179
|
+
/** Опускается в тонком конфиге (задан `preset`); иначе — полный словарь ролей. */
|
|
180
|
+
readonly roles?: ReadonlyArray<{ name: string; recipe: RoleRecipe }>;
|
|
120
181
|
readonly aliases?: ReadonlyArray<{ alias: string; target: string }>;
|
|
121
182
|
}
|
|
122
183
|
|
|
@@ -143,28 +204,22 @@ export interface ResolvedTheme {
|
|
|
143
204
|
|
|
144
205
|
|
|
145
206
|
/**
|
|
146
|
-
* A
|
|
147
|
-
* [`
|
|
148
|
-
*
|
|
207
|
+
* A contrast engine over a consumer-supplied design system. Construct with
|
|
208
|
+
* [`LabColors::new`], load a config with [`loadConfig`](LabColors::load_config),
|
|
209
|
+
* then call [`resolve_theme`](LabColors::resolve_theme) many times; identical
|
|
210
|
+
* calls are served from the contract cache.
|
|
149
211
|
*/
|
|
150
212
|
export class LabColors {
|
|
151
213
|
free(): void;
|
|
152
214
|
[Symbol.dispose](): void;
|
|
153
|
-
/**
|
|
154
|
-
* Calculate a relative confidence score for the [`muddiness`](Self::muddiness)
|
|
155
|
-
* call on an sRGB hex colour: `0` means the call is unreliable (near the
|
|
156
|
-
* decision boundary or the grey frontier), higher means more confident.
|
|
157
|
-
* The practical ceiling is an internal calibration detail, not a public
|
|
158
|
-
* contract — do not hardcode an upper bound against this value.
|
|
159
|
-
*/
|
|
160
|
-
confidence(hex: string): number;
|
|
161
215
|
/**
|
|
162
216
|
* Загрузить конфиг дизайн-системы (JSON по типу `ThemeConfig` из `.d.ts`).
|
|
163
217
|
*
|
|
164
218
|
* Полный preflight движка: невалидный конфиг отклоняется структурной
|
|
165
219
|
* ошибкой `invalid_config: …` и НЕ меняет состояние. После успешной
|
|
166
220
|
* загрузки `resolveTheme` эмитит роли конфига (включая полупрозрачные
|
|
167
|
-
* `
|
|
221
|
+
* роли лестницы — эмиссия `oklch(L% C H / α)`). Возвращает отпечаток
|
|
222
|
+
* конфига — 16 hex-символов;
|
|
168
223
|
* разные конфиги дают разные отпечатки (и разные кэш-пространства).
|
|
169
224
|
*/
|
|
170
225
|
loadConfig(json: string): string;
|
|
@@ -173,12 +228,11 @@ export class LabColors {
|
|
|
173
228
|
*/
|
|
174
229
|
muddiness(hex: string): number;
|
|
175
230
|
/**
|
|
176
|
-
* Create
|
|
177
|
-
* per-theme viewing conditions.
|
|
231
|
+
* Create an engine with no design system loaded.
|
|
178
232
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
233
|
+
* The engine is agnostic (ADR-0001): it carries no built-in role table, so
|
|
234
|
+
* [`resolveTheme`](LabColors::resolve_theme) rejects with `config_required`
|
|
235
|
+
* until [`loadConfig`](LabColors::load_config) supplies a design system.
|
|
182
236
|
*/
|
|
183
237
|
constructor();
|
|
184
238
|
/**
|
|
@@ -198,12 +252,12 @@ export class LabColors {
|
|
|
198
252
|
* Resolve every role for `bgHex` under `theme` (`"light" | "dark" |
|
|
199
253
|
* "light-ic" | "dark-ic"`).
|
|
200
254
|
*
|
|
201
|
-
* Returns a
|
|
255
|
+
* Returns a `ResolvedTheme` object. Per-role unreachability is part of a
|
|
202
256
|
* successful result (each role carries its own `kind`); only whole-call
|
|
203
|
-
* failures reject (
|
|
204
|
-
* by-construction-unreachable oklch serialisation
|
|
205
|
-
* `internal_error`) — as a structured `"<code>: <message>"`
|
|
206
|
-
* never an unwound panic.
|
|
257
|
+
* failures reject (no config loaded yet as `config_required`, invalid hex,
|
|
258
|
+
* unknown theme, and the by-construction-unreachable oklch serialisation
|
|
259
|
+
* failure as `internal_error`) — as a structured `"<code>: <message>"`
|
|
260
|
+
* error, never an unwound panic.
|
|
207
261
|
*/
|
|
208
262
|
resolveTheme(bg_hex: string, theme: string): ResolvedTheme;
|
|
209
263
|
}
|
|
@@ -213,7 +267,6 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
|
|
|
213
267
|
export interface InitOutput {
|
|
214
268
|
readonly memory: WebAssembly.Memory;
|
|
215
269
|
readonly __wbg_labcolors_free: (a: number, b: number) => void;
|
|
216
|
-
readonly labcolors_confidence: (a: number, b: number, c: number, d: number) => void;
|
|
217
270
|
readonly labcolors_loadConfig: (a: number, b: number, c: number, d: number) => void;
|
|
218
271
|
readonly labcolors_muddiness: (a: number, b: number, c: number, d: number) => void;
|
|
219
272
|
readonly labcolors_new: () => number;
|
package/pkg/labcolors.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* @ts-self-types="./labcolors.d.ts" */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A
|
|
5
|
-
* [`
|
|
6
|
-
*
|
|
4
|
+
* A contrast engine over a consumer-supplied design system. Construct with
|
|
5
|
+
* [`LabColors::new`], load a config with [`loadConfig`](LabColors::load_config),
|
|
6
|
+
* then call [`resolve_theme`](LabColors::resolve_theme) many times; identical
|
|
7
|
+
* calls are served from the contract cache.
|
|
7
8
|
*/
|
|
8
9
|
export class LabColors {
|
|
9
10
|
__destroy_into_raw() {
|
|
@@ -16,39 +17,14 @@ export class LabColors {
|
|
|
16
17
|
const ptr = this.__destroy_into_raw();
|
|
17
18
|
wasm.__wbg_labcolors_free(ptr, 0);
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Calculate a relative confidence score for the [`muddiness`](Self::muddiness)
|
|
21
|
-
* call on an sRGB hex colour: `0` means the call is unreliable (near the
|
|
22
|
-
* decision boundary or the grey frontier), higher means more confident.
|
|
23
|
-
* The practical ceiling is an internal calibration detail, not a public
|
|
24
|
-
* contract — do not hardcode an upper bound against this value.
|
|
25
|
-
* @param {string} hex
|
|
26
|
-
* @returns {number}
|
|
27
|
-
*/
|
|
28
|
-
confidence(hex) {
|
|
29
|
-
try {
|
|
30
|
-
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
31
|
-
const ptr0 = passStringToWasm0(hex, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
|
32
|
-
const len0 = WASM_VECTOR_LEN;
|
|
33
|
-
wasm.labcolors_confidence(retptr, this.__wbg_ptr, ptr0, len0);
|
|
34
|
-
var r0 = getDataViewMemory0().getFloat64(retptr + 8 * 0, true);
|
|
35
|
-
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
36
|
-
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
|
|
37
|
-
if (r3) {
|
|
38
|
-
throw takeObject(r2);
|
|
39
|
-
}
|
|
40
|
-
return r0;
|
|
41
|
-
} finally {
|
|
42
|
-
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
20
|
/**
|
|
46
21
|
* Загрузить конфиг дизайн-системы (JSON по типу `ThemeConfig` из `.d.ts`).
|
|
47
22
|
*
|
|
48
23
|
* Полный preflight движка: невалидный конфиг отклоняется структурной
|
|
49
24
|
* ошибкой `invalid_config: …` и НЕ меняет состояние. После успешной
|
|
50
25
|
* загрузки `resolveTheme` эмитит роли конфига (включая полупрозрачные
|
|
51
|
-
* `
|
|
26
|
+
* роли лестницы — эмиссия `oklch(L% C H / α)`). Возвращает отпечаток
|
|
27
|
+
* конфига — 16 hex-символов;
|
|
52
28
|
* разные конфиги дают разные отпечатки (и разные кэш-пространства).
|
|
53
29
|
* @param {string} json
|
|
54
30
|
* @returns {string}
|
|
@@ -102,12 +78,11 @@ export class LabColors {
|
|
|
102
78
|
}
|
|
103
79
|
}
|
|
104
80
|
/**
|
|
105
|
-
* Create
|
|
106
|
-
* per-theme viewing conditions.
|
|
81
|
+
* Create an engine with no design system loaded.
|
|
107
82
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
83
|
+
* The engine is agnostic (ADR-0001): it carries no built-in role table, so
|
|
84
|
+
* [`resolveTheme`](LabColors::resolve_theme) rejects with `config_required`
|
|
85
|
+
* until [`loadConfig`](LabColors::load_config) supplies a design system.
|
|
111
86
|
*/
|
|
112
87
|
constructor() {
|
|
113
88
|
const ret = wasm.labcolors_new();
|
|
@@ -159,12 +134,12 @@ export class LabColors {
|
|
|
159
134
|
* Resolve every role for `bgHex` under `theme` (`"light" | "dark" |
|
|
160
135
|
* "light-ic" | "dark-ic"`).
|
|
161
136
|
*
|
|
162
|
-
* Returns a
|
|
137
|
+
* Returns a `ResolvedTheme` object. Per-role unreachability is part of a
|
|
163
138
|
* successful result (each role carries its own `kind`); only whole-call
|
|
164
|
-
* failures reject (
|
|
165
|
-
* by-construction-unreachable oklch serialisation
|
|
166
|
-
* `internal_error`) — as a structured `"<code>: <message>"`
|
|
167
|
-
* never an unwound panic.
|
|
139
|
+
* failures reject (no config loaded yet as `config_required`, invalid hex,
|
|
140
|
+
* unknown theme, and the by-construction-unreachable oklch serialisation
|
|
141
|
+
* failure as `internal_error`) — as a structured `"<code>: <message>"`
|
|
142
|
+
* error, never an unwound panic.
|
|
168
143
|
* @param {string} bg_hex
|
|
169
144
|
* @param {string} theme
|
|
170
145
|
* @returns {ResolvedTheme}
|
package/pkg/labcolors_bg.wasm
CHANGED
|
Binary file
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
export const memory: WebAssembly.Memory;
|
|
4
4
|
export const __wbg_labcolors_free: (a: number, b: number) => void;
|
|
5
|
-
export const labcolors_confidence: (a: number, b: number, c: number, d: number) => void;
|
|
6
5
|
export const labcolors_loadConfig: (a: number, b: number, c: number, d: number) => void;
|
|
7
6
|
export const labcolors_muddiness: (a: number, b: number, c: number, d: number) => void;
|
|
8
7
|
export const labcolors_new: () => number;
|