@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @labpics/colors
2
2
 
3
- Адаптивные цветовые роли для дизайн-системы. Получает фоновый цвет и тему — возвращает полный набор ролей (`--lab-label-primary`, `--lab-icon`, `--lab-border-base`, …) как `#RRGGBB`-значения CSS-переменных. Ядро написано на Rust и скомпилировано в WebAssembly; пакет не имеет runtime-зависимостей.
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": "#1a1a1a", "--lab-icon": "#5b5b5b", ... }
37
- // result.roles детали каждой роли (hex, контраст, флаги)
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-<ключ>" → hex
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"`, поля `hex`, `lc`, `wcagRatio`, …).
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 | brotli |
236
- |----------|-----|------|--------|
237
- | `labcolors_bg.wasm` | ~313 КБ | ~138 КБ | ~116 КБ |
238
- | `labcolors.js` (JS-обёртка) | ~17 КБ | ~4 КБ | ~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
- const applyHexes = (hexByVar) => applyTheme(target, { vars: hexByVar });
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
- const applyRolesDirect = () => {
247
- const vars = {};
248
- for (const r of roles) vars[r.cssVar] = r.hex;
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
- const vars = {};
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
- vars[r.cssVar] = lerpHex(seg.from, seg.to, blend);
339
+ overlay[r.cssVar] = lerpHex(seg.from, seg.to, blend);
315
340
  }
316
- applyHexes(vars);
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 ? seg.to : r.hex; // logical target during/after ease
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 `#RRGGBB` colours in Oklab at `t ∈ [0,1]` → `#RRGGBB`.
33
- * Perceptually uniform (even crossfade timing, non-muddy chroma path); endpoints
34
- * are exact and out-of-gamut intermediates are clamped per channel.
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(fromHex: string, toHex_: string, t: number): string;
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 (`rgb(r, g, b)`,
28
- * `rgba(r, g, b, a)`, the modern `rgb(r g b / a)`) plus `#rgb`/`#rrggbb` and the
29
- * `transparent` keyword. Unknown keywords return `null` (treated as "no layer").
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 `#RRGGBB` colours in Oklab at `t ∈ [0,1]`, returning `#RRGGBB`.
259
+ * Interpolate two colours in Oklab at `t ∈ [0,1]`, returning `#RRGGBB`.
160
260
  *
161
- * Perceptually uniform: equal steps in `t` are equal steps in perceived
162
- * lightness (and a straight, non-muddy path in hue/chroma), so a crossfade feels
163
- * even rather than lingering bright. Endpoints are returned exactly (`t 0`
164
- * `from`, `t 1` `to`, both re-normalised through `toHex`); out-of-gamut
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} fromHex
169
- * @param {string} toHex_
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(fromHex, toHex_, t) {
174
- const a = parseCssColor(fromHex);
175
- const b = parseCssColor(toHex_);
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
@@ -14,6 +14,7 @@ export {
14
14
  export type {
15
15
  ThemeName,
16
16
  SolvedColor,
17
+ TranslucentRole,
17
18
  NoneRole,
18
19
  UnreachableRole,
19
20
  RoleResult,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labpics/colors",
3
- "version": "0.5.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",
@@ -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
- export type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole;
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
- readonly roles: ReadonlyArray<{ name: string; recipe: RoleRecipe }>;
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 configured contrast engine. Construct with [`LabColors::new`], then call
147
- * [`resolve_theme`](LabColors::resolve_theme) many times; identical calls are
148
- * served from the contract cache.
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
- * `rgba`-роли лестницы). Возвращает отпечаток конфига — 16 hex-символов;
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 a zero-config engine on the default role table and the default
177
- * per-theme viewing conditions.
231
+ * Create an engine with no design system loaded.
178
232
  *
179
- * `v1` takes no config; the brand/anchor seam is left for a future
180
- * version. Adding an optional config object later is additive — it does
181
- * not change this signature.
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 [`ResolvedTheme`] object. Per-role unreachability is part of 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 (invalid hex, unknown theme, and the
204
- * by-construction-unreachable oklch serialisation failure as
205
- * `internal_error`) — as a structured `"<code>: <message>"` error,
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 configured contrast engine. Construct with [`LabColors::new`], then call
5
- * [`resolve_theme`](LabColors::resolve_theme) many times; identical calls are
6
- * served from the contract cache.
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
- * `rgba`-роли лестницы). Возвращает отпечаток конфига — 16 hex-символов;
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 a zero-config engine on the default role table and the default
106
- * per-theme viewing conditions.
81
+ * Create an engine with no design system loaded.
107
82
  *
108
- * `v1` takes no config; the brand/anchor seam is left for a future
109
- * version. Adding an optional config object later is additive — it does
110
- * not change this signature.
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 [`ResolvedTheme`] object. Per-role unreachability is part of 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 (invalid hex, unknown theme, and the
165
- * by-construction-unreachable oklch serialisation failure as
166
- * `internal_error`) — as a structured `"<code>: <message>"` error,
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}
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;