@konce-pt/color 0.9.0
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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/contrast.d.ts +36 -0
- package/dist/contrast.js +81 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/oklab.d.ts +68 -0
- package/dist/oklab.js +138 -0
- package/dist/oklch.d.ts +50 -0
- package/dist/oklch.js +104 -0
- package/dist/srgb.d.ts +42 -0
- package/dist/srgb.js +84 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 konce.pt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://ui.konce.pt/images/koncept_ui_lib.png" alt="Koncept UI — Angular component library" width="100%" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# @konce-pt/color
|
|
6
|
+
|
|
7
|
+
Rachunek barw [Koncept UI](https://gitlab.com/konce-pt/koncept-ui): konwersje sRGB ↔ OKLab ↔ OKLCH,
|
|
8
|
+
mieszanie po kole odcieni, sprowadzanie barwy do gamutu sRGB oraz kontrast w dwóch miarach —
|
|
9
|
+
WCAG 2.1 i APCA. Czysty TypeScript, **zero zależności runtime**, bez wiązania z frameworkiem.
|
|
10
|
+
|
|
11
|
+
Paleta biblioteki jest zapisana w OKLCH i przechodzi przez build **verbatim**, bez transformacji
|
|
12
|
+
kolorów. Żeby cokolwiek z tymi wartościami policzyć — wymieszać dwie barwy, wyprowadzić rampę
|
|
13
|
+
z jednego ziarna, sprawdzić czy napis na tle da się przeczytać — potrzebny jest model, w którym
|
|
14
|
+
jasność znaczy jasność. Tym modelem jest OKLab i to jest ta paczka.
|
|
15
|
+
|
|
16
|
+
Zwykle nie instalujesz jej samodzielnie: używa jej `KptBackdrop` z `@konce-pt/angular`
|
|
17
|
+
i z `@konce-pt/react`. Sięgnij po nią wprost, gdy liczysz barwy po swojej stronie.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i @konce-pt/color
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { clampToGamut, formatOklch, parseOklch, apcaLc, parseHexColor } from '@konce-pt/color';
|
|
25
|
+
|
|
26
|
+
// Odcień marki przeniesiony na tę samą jasność co token biblioteki.
|
|
27
|
+
const token = parseOklch('oklch(0.546 0.190 255)');
|
|
28
|
+
const brand = { ...token, h: 152 };
|
|
29
|
+
|
|
30
|
+
formatOklch(clampToGamut(brand)); // 'oklch(0.546 0.161 152)' — nasycenie zbite do gamutu sRGB
|
|
31
|
+
|
|
32
|
+
// Czy biały napis na tym tle da się przeczytać? |Lc| 75 to próg tekstu podstawowego.
|
|
33
|
+
apcaLc(parseHexColor('#ffffff'), parseHexColor('#3b82f6')); // -66.4 → za mało
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Dlaczego po kole, a nie po prostej
|
|
37
|
+
|
|
38
|
+
Interpolacja w sRGB miesza wartości po gammie i prowadzi przejście przez błoto. Prostokątny OKLab
|
|
39
|
+
naprawia jasność, ale dla odcieni leżących naprzeciwko siebie przecina oś achromatyczną: w połowie
|
|
40
|
+
przejścia z niebieskiego w żółty zostaje szarość dokładnie tam, gdzie mieszanka ma świecić
|
|
41
|
+
najmocniej. `mixOklch` idzie krótszym łukiem koła z zachowanym nasyceniem, a przystanek bez
|
|
42
|
+
nasycenia przejmuje odcień sąsiada — inaczej `atan2` na zerowej chromie zwraca kąt przypadkowy
|
|
43
|
+
i barwa szarpie.
|
|
44
|
+
|
|
45
|
+
Z tego samego powodu `clampToGamut` obniża **samo nasycenie**, zamiast przycinać składowe RGB:
|
|
46
|
+
przycięta po kanałach barwa marki potrafi zmienić odcień o kilkanaście stopni, czyli przestać być
|
|
47
|
+
tą barwą.
|
|
48
|
+
|
|
49
|
+
## Dwie miary kontrastu
|
|
50
|
+
|
|
51
|
+
`contrastRatio` to WCAG 2.1 — symetryczna liczba 1–21, progi 4.5 (tekst) i 3 (tekst duży,
|
|
52
|
+
elementy interfejsu). `apcaLc` to APCA: wartość ze znakiem, gdzie znak niesie biegunowość
|
|
53
|
+
(dodatni — treść ciemna na jasnym tle), a kolejność argumentów **ma** znaczenie, bo ta sama para
|
|
54
|
+
barw czyta się inaczej w każdą stronę. Progi |Lc|: 75 tekst podstawowy, 60 tekst większy,
|
|
55
|
+
45 elementy interfejsu.
|
|
56
|
+
|
|
57
|
+
Pełny opis: [`llms.txt`](https://ui.konce.pt/llms/color/llms.txt) (EN)
|
|
58
|
+
i [`llms-pl.txt`](https://ui.konce.pt/llms/color/llms-pl.txt) (PL).
|
|
59
|
+
|
|
60
|
+
## Testy
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm --filter @konce-pt/color exec node --test "src/**/*.test.ts"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Licencja
|
|
67
|
+
|
|
68
|
+
MIT © [konce.pt](https://konce.pt)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kontrast: ile światła wychodzi z barwy i czy para barw nadaje się na tekst.
|
|
3
|
+
*
|
|
4
|
+
* To nie jest ta sama wielkość co jasność OKLab. OKLab opisuje, jak jasno barwa *wygląda*;
|
|
5
|
+
* tutaj liczy się, ile z niej wychodzi światła — i tylko na tym stoi rachunek kontrastu.
|
|
6
|
+
*/
|
|
7
|
+
import type { KptRgb } from './srgb.ts';
|
|
8
|
+
/** Jasność względna wg WCAG (0 = czerń, 1 = biel). */
|
|
9
|
+
export declare function relativeLuminance(rgb: KptRgb): number;
|
|
10
|
+
/**
|
|
11
|
+
* Czy na tej powierzchni czytelniejsza jest treść ciemna.
|
|
12
|
+
*
|
|
13
|
+
* Sekcja z własnym tłem przestaje być powierzchnią strony, więc `--kpt-color-on-surface` opisuje
|
|
14
|
+
* już nie to tło, tylko sąsiada. Bez tego rachunku treść w sekcji z ciemnym tłem w motywie jasnym
|
|
15
|
+
* wychodzi czarna na czarnym.
|
|
16
|
+
*/
|
|
17
|
+
export declare function prefersDarkContent(surface: KptRgb): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Kontrast wg WCAG 2.1 — liczba od 1 (te same barwy) do 21 (czerń na bieli).
|
|
20
|
+
*
|
|
21
|
+
* Progi ze specyfikacji: 4.5 dla tekstu, 3 dla tekstu dużego i dla elementów interfejsu.
|
|
22
|
+
* Kolejność argumentów nie ma znaczenia, bo wzór jest symetryczny.
|
|
23
|
+
*/
|
|
24
|
+
export declare function contrastRatio(a: KptRgb, b: KptRgb): number;
|
|
25
|
+
/**
|
|
26
|
+
* Kontrast APCA (Lc) — wartość ze znakiem w zakresie mniej więcej −108…+106.
|
|
27
|
+
*
|
|
28
|
+
* Znak niesie biegunowość: dodatni to treść ciemna na jasnym tle, ujemny odwrotnie. Poziomy
|
|
29
|
+
* z roboczych wytycznych: |Lc| 75 dla tekstu podstawowego, 60 dla tekstu większego, 45 dla
|
|
30
|
+
* elementów interfejsu i tekstu wyłączonego.
|
|
31
|
+
*
|
|
32
|
+
* W odróżnieniu od WCAG 2.1 APCA uwzględnia biegunowość i rozmiar, więc para barw daje dwa różne
|
|
33
|
+
* wyniki zależnie od tego, która jest tłem — dlatego kolejność argumentów **ma** tu znaczenie.
|
|
34
|
+
* Poniżej `deltaYmin` zwraca 0 zamiast szumu numerycznego.
|
|
35
|
+
*/
|
|
36
|
+
export declare function apcaLc(text: KptRgb, background: KptRgb): number;
|
package/dist/contrast.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kontrast: ile światła wychodzi z barwy i czy para barw nadaje się na tekst.
|
|
3
|
+
*
|
|
4
|
+
* To nie jest ta sama wielkość co jasność OKLab. OKLab opisuje, jak jasno barwa *wygląda*;
|
|
5
|
+
* tutaj liczy się, ile z niej wychodzi światła — i tylko na tym stoi rachunek kontrastu.
|
|
6
|
+
*/
|
|
7
|
+
import { toLinear } from "./srgb.js";
|
|
8
|
+
/** Jasność względna wg WCAG (0 = czerń, 1 = biel). */
|
|
9
|
+
export function relativeLuminance(rgb) {
|
|
10
|
+
return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b);
|
|
11
|
+
}
|
|
12
|
+
/** Próg WCAG na wybór treści ciemnej albo jasnej — powyżej niego ciemna wygrywa kontrastem. */
|
|
13
|
+
const CONTENT_THRESHOLD = 0.179;
|
|
14
|
+
/**
|
|
15
|
+
* Czy na tej powierzchni czytelniejsza jest treść ciemna.
|
|
16
|
+
*
|
|
17
|
+
* Sekcja z własnym tłem przestaje być powierzchnią strony, więc `--kpt-color-on-surface` opisuje
|
|
18
|
+
* już nie to tło, tylko sąsiada. Bez tego rachunku treść w sekcji z ciemnym tłem w motywie jasnym
|
|
19
|
+
* wychodzi czarna na czarnym.
|
|
20
|
+
*/
|
|
21
|
+
export function prefersDarkContent(surface) {
|
|
22
|
+
return relativeLuminance(surface) > CONTENT_THRESHOLD;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Kontrast wg WCAG 2.1 — liczba od 1 (te same barwy) do 21 (czerń na bieli).
|
|
26
|
+
*
|
|
27
|
+
* Progi ze specyfikacji: 4.5 dla tekstu, 3 dla tekstu dużego i dla elementów interfejsu.
|
|
28
|
+
* Kolejność argumentów nie ma znaczenia, bo wzór jest symetryczny.
|
|
29
|
+
*/
|
|
30
|
+
export function contrastRatio(a, b) {
|
|
31
|
+
const la = relativeLuminance(a);
|
|
32
|
+
const lb = relativeLuminance(b);
|
|
33
|
+
const lighter = Math.max(la, lb);
|
|
34
|
+
const darker = Math.min(la, lb);
|
|
35
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
36
|
+
}
|
|
37
|
+
// Stałe APCA-W3 0.1.9 (G-4g), z publicznej specyfikacji. Nie ma sensu ich rozdzielać na nazwane
|
|
38
|
+
// zmienne per rodzaj — działają wyłącznie jako komplet i tylko w tym jednym wzorze.
|
|
39
|
+
const APCA_TRC = 2.4;
|
|
40
|
+
const APCA_BLACK_THRESHOLD = 0.022;
|
|
41
|
+
const APCA_BLACK_CLAMP = 1.414;
|
|
42
|
+
const APCA_DELTA_Y_MIN = 0.0005;
|
|
43
|
+
const APCA_LO_CLIP = 0.1;
|
|
44
|
+
const APCA_SCALE = 1.14;
|
|
45
|
+
const APCA_LO_OFFSET = 0.027;
|
|
46
|
+
const APCA_NORM_BG = 0.56;
|
|
47
|
+
const APCA_NORM_TXT = 0.57;
|
|
48
|
+
const APCA_REV_TXT = 0.62;
|
|
49
|
+
const APCA_REV_BG = 0.65;
|
|
50
|
+
/** Luminancja APCA — inne współczynniki i inna krzywa niż WCAG, więc osobny rachunek. */
|
|
51
|
+
const apcaY = (rgb) => {
|
|
52
|
+
const y = (rgb.r / 255) ** APCA_TRC * 0.2126729 +
|
|
53
|
+
(rgb.g / 255) ** APCA_TRC * 0.7151522 +
|
|
54
|
+
(rgb.b / 255) ** APCA_TRC * 0.072175;
|
|
55
|
+
// Miękkie przycięcie czerni: poniżej progu oko przestaje rozróżniać, a wzór bez tego
|
|
56
|
+
// zawyżałby kontrast ciemnych par.
|
|
57
|
+
return y < APCA_BLACK_THRESHOLD ? y + (APCA_BLACK_THRESHOLD - y) ** APCA_BLACK_CLAMP : y;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Kontrast APCA (Lc) — wartość ze znakiem w zakresie mniej więcej −108…+106.
|
|
61
|
+
*
|
|
62
|
+
* Znak niesie biegunowość: dodatni to treść ciemna na jasnym tle, ujemny odwrotnie. Poziomy
|
|
63
|
+
* z roboczych wytycznych: |Lc| 75 dla tekstu podstawowego, 60 dla tekstu większego, 45 dla
|
|
64
|
+
* elementów interfejsu i tekstu wyłączonego.
|
|
65
|
+
*
|
|
66
|
+
* W odróżnieniu od WCAG 2.1 APCA uwzględnia biegunowość i rozmiar, więc para barw daje dwa różne
|
|
67
|
+
* wyniki zależnie od tego, która jest tłem — dlatego kolejność argumentów **ma** tu znaczenie.
|
|
68
|
+
* Poniżej `deltaYmin` zwraca 0 zamiast szumu numerycznego.
|
|
69
|
+
*/
|
|
70
|
+
export function apcaLc(text, background) {
|
|
71
|
+
const yTxt = apcaY(text);
|
|
72
|
+
const yBg = apcaY(background);
|
|
73
|
+
if (Math.abs(yBg - yTxt) < APCA_DELTA_Y_MIN)
|
|
74
|
+
return 0;
|
|
75
|
+
if (yBg > yTxt) {
|
|
76
|
+
const sapc = (yBg ** APCA_NORM_BG - yTxt ** APCA_NORM_TXT) * APCA_SCALE;
|
|
77
|
+
return sapc < APCA_LO_CLIP ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
78
|
+
}
|
|
79
|
+
const sapc = (yBg ** APCA_REV_BG - yTxt ** APCA_REV_TXT) * APCA_SCALE;
|
|
80
|
+
return sapc > -APCA_LO_CLIP ? 0 : (sapc + APCA_LO_OFFSET) * 100;
|
|
81
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { KptRgb } from './srgb.ts';
|
|
2
|
+
export { clamp255, parseHexColor, readCssColor, toGamma, toHexColor, toLinear } from './srgb.ts';
|
|
3
|
+
export type { KptOklab } from './oklab.ts';
|
|
4
|
+
export { adaptLightness, adaptToSurface, mixOklch, oklabToLinearSrgb, oklabToSrgb, shiftHue, srgbToOklab, ACHROMATIC, TAU, } from './oklab.ts';
|
|
5
|
+
export type { KptOklch } from './oklch.ts';
|
|
6
|
+
export { clampToGamut, formatOklch, isInGamut, oklabToOklch, oklchToOklab, oklchToSrgb, parseOklch, srgbToOklch, } from './oklch.ts';
|
|
7
|
+
export { apcaLc, contrastRatio, prefersDarkContent, relativeLuminance } from './contrast.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { clamp255, parseHexColor, readCssColor, toGamma, toHexColor, toLinear } from "./srgb.js";
|
|
2
|
+
export { adaptLightness, adaptToSurface, mixOklch, oklabToLinearSrgb, oklabToSrgb, shiftHue, srgbToOklab, ACHROMATIC, TAU, } from "./oklab.js";
|
|
3
|
+
export { clampToGamut, formatOklch, isInGamut, oklabToOklch, oklchToOklab, oklchToSrgb, parseOklch, srgbToOklch, } from "./oklch.js";
|
|
4
|
+
export { apcaLc, contrastRatio, prefersDarkContent, relativeLuminance } from "./contrast.js";
|
package/dist/oklab.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model OKLab Björna Ottossona: konwersje sRGB ↔ OKLab, mieszanie po współrzędnych biegunowych
|
|
3
|
+
* i przenoszenie barwy na inną powierzchnię.
|
|
4
|
+
*
|
|
5
|
+
* Interpolacja sRGB prowadzi mieszankę przez błoto, bo miesza wartości po gammie. Prostokątny
|
|
6
|
+
* OKLab naprawia jasność, ale dla odcieni leżących naprzeciwko siebie i tak przecina oś
|
|
7
|
+
* achromatyczną: w połowie przejścia z niebieskiego w żółty zostaje szarość dokładnie tam, gdzie
|
|
8
|
+
* mieszanka ma świecić najmocniej. Dopiero rozbicie na jasność, nasycenie i kąt odcienia pozwala
|
|
9
|
+
* obejść koło dookoła z zachowanym nasyceniem.
|
|
10
|
+
*/
|
|
11
|
+
import type { KptRgb } from './srgb.ts';
|
|
12
|
+
/** Kolor w OKLab: jasność `l` oraz para składowych chromatycznych. */
|
|
13
|
+
export interface KptOklab {
|
|
14
|
+
l: number;
|
|
15
|
+
a: number;
|
|
16
|
+
b: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const TAU: number;
|
|
19
|
+
export declare function srgbToOklab(rgb: KptRgb): KptOklab;
|
|
20
|
+
/**
|
|
21
|
+
* OKLab na liniowe sRGB **bez przycinania** — składowa poza zakresem 0–1 oznacza barwę spoza
|
|
22
|
+
* gamutu sRGB. Na tym stoi `clampToGamut`, bo `oklabToSrgb` przycina po cichu i po nim nie da się
|
|
23
|
+
* już odróżnić barwy leżącej dokładnie na krawędzi od takiej, która wypadła daleko poza nią.
|
|
24
|
+
*/
|
|
25
|
+
export declare function oklabToLinearSrgb(lab: KptOklab): {
|
|
26
|
+
r: number;
|
|
27
|
+
g: number;
|
|
28
|
+
b: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function oklabToSrgb(lab: KptOklab): KptRgb;
|
|
31
|
+
/**
|
|
32
|
+
* Obrót odcienia o zadany kąt (stopnie) przy zachowanej jasności i nasyceniu.
|
|
33
|
+
*
|
|
34
|
+
* Stąd biorą się domyślne barwy towarzyszące: paleta biblioteki jest neutralna, więc zamiast
|
|
35
|
+
* sięgać po tokeny statusowe (`info`, `success`) do celów dekoracyjnych, barwy pochodne
|
|
36
|
+
* wyprowadzamy z koloru wiodącego. Dekoracja zostaje w kolorach aplikacji niezależnie od motywu.
|
|
37
|
+
*/
|
|
38
|
+
export declare function shiftHue(rgb: KptRgb, degrees: number): KptRgb;
|
|
39
|
+
/** Poniżej tego nasycenia odcień przestaje cokolwiek znaczyć — szarość nie ma kąta. */
|
|
40
|
+
export declare const ACHROMATIC = 0.0001;
|
|
41
|
+
/**
|
|
42
|
+
* Kolor na zamkniętej rampie `colors` w miejscu `position`.
|
|
43
|
+
*
|
|
44
|
+
* Rampa jest cykliczna: ostatni przystanek wraca do pierwszego, bo barwy przesuwają się bez końca
|
|
45
|
+
* i szew w środku byłby widoczny. `position` poza zakresem 0–1 zawija się, więc wywołujący nie
|
|
46
|
+
* musi pilnować rosnącego czasu.
|
|
47
|
+
*
|
|
48
|
+
* Odcień idzie krótszym łukiem koła. Przystanek bez nasycenia przejmuje odcień sąsiada — inaczej
|
|
49
|
+
* przejście do szarości i z powrotem szarpnęłoby barwą, bo `atan2` na zerowym nasyceniu zwraca
|
|
50
|
+
* kąt przypadkowy.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mixOklch(colors: readonly KptRgb[], position: number): KptRgb;
|
|
53
|
+
/**
|
|
54
|
+
* Jasność przeniesiona na powierzchnię bieżącego motywu z zachowaniem odległości od niej.
|
|
55
|
+
*
|
|
56
|
+
* Barwa zapisana na biel odsuwa się od niej o `d`; na ciemnej powierzchni ma się odsunąć o to samo
|
|
57
|
+
* `d`, tyle że w drugą stronę — bo tam właśnie jest miejsce. Stąd czerń na bieli staje się bielą
|
|
58
|
+
* na ciemnym tle, a biel staje się dokładnie powierzchnią. Barwa w połowie skali zostaje mniej
|
|
59
|
+
* więcej tam, gdzie była, i dalej odcina się od tła.
|
|
60
|
+
*
|
|
61
|
+
* Na jasnej powierzchni wychodzi tożsamość: `1 - (1 - L)` to `L`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function adaptLightness(lightness: number, surface: number): number;
|
|
64
|
+
/**
|
|
65
|
+
* Barwa przeniesiona na zadaną powierzchnię. Rusza wyłącznie jasność — odcień i nasycenie zostają,
|
|
66
|
+
* więc barwa marki nie zmienia się w inną barwę, tylko dobiera jasność do tła.
|
|
67
|
+
*/
|
|
68
|
+
export declare function adaptToSurface(rgb: KptRgb, surface: KptRgb): KptRgb;
|
package/dist/oklab.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model OKLab Björna Ottossona: konwersje sRGB ↔ OKLab, mieszanie po współrzędnych biegunowych
|
|
3
|
+
* i przenoszenie barwy na inną powierzchnię.
|
|
4
|
+
*
|
|
5
|
+
* Interpolacja sRGB prowadzi mieszankę przez błoto, bo miesza wartości po gammie. Prostokątny
|
|
6
|
+
* OKLab naprawia jasność, ale dla odcieni leżących naprzeciwko siebie i tak przecina oś
|
|
7
|
+
* achromatyczną: w połowie przejścia z niebieskiego w żółty zostaje szarość dokładnie tam, gdzie
|
|
8
|
+
* mieszanka ma świecić najmocniej. Dopiero rozbicie na jasność, nasycenie i kąt odcienia pozwala
|
|
9
|
+
* obejść koło dookoła z zachowanym nasyceniem.
|
|
10
|
+
*/
|
|
11
|
+
import { toGamma, toLinear } from "./srgb.js";
|
|
12
|
+
export const TAU = Math.PI * 2;
|
|
13
|
+
export function srgbToOklab(rgb) {
|
|
14
|
+
const r = toLinear(rgb.r);
|
|
15
|
+
const g = toLinear(rgb.g);
|
|
16
|
+
const b = toLinear(rgb.b);
|
|
17
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
18
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
19
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
20
|
+
return {
|
|
21
|
+
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
22
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
23
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* OKLab na liniowe sRGB **bez przycinania** — składowa poza zakresem 0–1 oznacza barwę spoza
|
|
28
|
+
* gamutu sRGB. Na tym stoi `clampToGamut`, bo `oklabToSrgb` przycina po cichu i po nim nie da się
|
|
29
|
+
* już odróżnić barwy leżącej dokładnie na krawędzi od takiej, która wypadła daleko poza nią.
|
|
30
|
+
*/
|
|
31
|
+
export function oklabToLinearSrgb(lab) {
|
|
32
|
+
const l = (lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b) ** 3;
|
|
33
|
+
const m = (lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b) ** 3;
|
|
34
|
+
const s = (lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b) ** 3;
|
|
35
|
+
return {
|
|
36
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
37
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
38
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function oklabToSrgb(lab) {
|
|
42
|
+
const linear = oklabToLinearSrgb(lab);
|
|
43
|
+
return { r: toGamma(linear.r), g: toGamma(linear.g), b: toGamma(linear.b) };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Obrót odcienia o zadany kąt (stopnie) przy zachowanej jasności i nasyceniu.
|
|
47
|
+
*
|
|
48
|
+
* Stąd biorą się domyślne barwy towarzyszące: paleta biblioteki jest neutralna, więc zamiast
|
|
49
|
+
* sięgać po tokeny statusowe (`info`, `success`) do celów dekoracyjnych, barwy pochodne
|
|
50
|
+
* wyprowadzamy z koloru wiodącego. Dekoracja zostaje w kolorach aplikacji niezależnie od motywu.
|
|
51
|
+
*/
|
|
52
|
+
export function shiftHue(rgb, degrees) {
|
|
53
|
+
const lab = srgbToOklab(rgb);
|
|
54
|
+
const chroma = Math.hypot(lab.a, lab.b);
|
|
55
|
+
const hue = Math.atan2(lab.b, lab.a) + (degrees / 360) * TAU;
|
|
56
|
+
return oklabToSrgb({ l: lab.l, a: Math.cos(hue) * chroma, b: Math.sin(hue) * chroma });
|
|
57
|
+
}
|
|
58
|
+
/** Poniżej tego nasycenia odcień przestaje cokolwiek znaczyć — szarość nie ma kąta. */
|
|
59
|
+
export const ACHROMATIC = 1e-4;
|
|
60
|
+
/**
|
|
61
|
+
* Kolor na zamkniętej rampie `colors` w miejscu `position`.
|
|
62
|
+
*
|
|
63
|
+
* Rampa jest cykliczna: ostatni przystanek wraca do pierwszego, bo barwy przesuwają się bez końca
|
|
64
|
+
* i szew w środku byłby widoczny. `position` poza zakresem 0–1 zawija się, więc wywołujący nie
|
|
65
|
+
* musi pilnować rosnącego czasu.
|
|
66
|
+
*
|
|
67
|
+
* Odcień idzie krótszym łukiem koła. Przystanek bez nasycenia przejmuje odcień sąsiada — inaczej
|
|
68
|
+
* przejście do szarości i z powrotem szarpnęłoby barwą, bo `atan2` na zerowym nasyceniu zwraca
|
|
69
|
+
* kąt przypadkowy.
|
|
70
|
+
*/
|
|
71
|
+
export function mixOklch(colors, position) {
|
|
72
|
+
if (colors.length === 0)
|
|
73
|
+
return { r: 0, g: 0, b: 0 };
|
|
74
|
+
if (colors.length === 1)
|
|
75
|
+
return colors[0];
|
|
76
|
+
const wrapped = position - Math.floor(position);
|
|
77
|
+
const scaled = wrapped * colors.length;
|
|
78
|
+
const index = Math.min(Math.floor(scaled), colors.length - 1);
|
|
79
|
+
const blend = scaled - index;
|
|
80
|
+
const from = srgbToOklab(colors[index]);
|
|
81
|
+
const to = srgbToOklab(colors[(index + 1) % colors.length]);
|
|
82
|
+
const fromChroma = Math.hypot(from.a, from.b);
|
|
83
|
+
const toChroma = Math.hypot(to.a, to.b);
|
|
84
|
+
const chroma = fromChroma + (toChroma - fromChroma) * blend;
|
|
85
|
+
let hue;
|
|
86
|
+
if (fromChroma < ACHROMATIC && toChroma < ACHROMATIC) {
|
|
87
|
+
hue = 0;
|
|
88
|
+
}
|
|
89
|
+
else if (fromChroma < ACHROMATIC) {
|
|
90
|
+
hue = Math.atan2(to.b, to.a);
|
|
91
|
+
}
|
|
92
|
+
else if (toChroma < ACHROMATIC) {
|
|
93
|
+
hue = Math.atan2(from.b, from.a);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const start = Math.atan2(from.b, from.a);
|
|
97
|
+
const end = Math.atan2(to.b, to.a);
|
|
98
|
+
// Krótszy łuk: różnicę sprowadzamy do (-π, π], zamiast iść dookoła przez pół koła.
|
|
99
|
+
const delta = ((end - start + Math.PI * 3) % TAU) - Math.PI;
|
|
100
|
+
hue = start + delta * blend;
|
|
101
|
+
}
|
|
102
|
+
return oklabToSrgb({
|
|
103
|
+
l: from.l + (to.l - from.l) * blend,
|
|
104
|
+
a: Math.cos(hue) * chroma,
|
|
105
|
+
b: Math.sin(hue) * chroma,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Jasność powierzchni, względem której zapisano barwę. Biblioteka trzyma motyw jasny w `:root`,
|
|
110
|
+
* a ciemny jako nadpisanie, więc wartość wpisana do szablonu jest z założenia dobrana do jasnego —
|
|
111
|
+
* i to jest jedyne założenie, jakie robi adaptacja.
|
|
112
|
+
*/
|
|
113
|
+
const REFERENCE_SURFACE = 1;
|
|
114
|
+
/** Powyżej tej jasności powierzchnię uznajemy za jasną, poniżej za ciemną. */
|
|
115
|
+
const SURFACE_MIDPOINT = 0.5;
|
|
116
|
+
const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
|
|
117
|
+
/**
|
|
118
|
+
* Jasność przeniesiona na powierzchnię bieżącego motywu z zachowaniem odległości od niej.
|
|
119
|
+
*
|
|
120
|
+
* Barwa zapisana na biel odsuwa się od niej o `d`; na ciemnej powierzchni ma się odsunąć o to samo
|
|
121
|
+
* `d`, tyle że w drugą stronę — bo tam właśnie jest miejsce. Stąd czerń na bieli staje się bielą
|
|
122
|
+
* na ciemnym tle, a biel staje się dokładnie powierzchnią. Barwa w połowie skali zostaje mniej
|
|
123
|
+
* więcej tam, gdzie była, i dalej odcina się od tła.
|
|
124
|
+
*
|
|
125
|
+
* Na jasnej powierzchni wychodzi tożsamość: `1 - (1 - L)` to `L`.
|
|
126
|
+
*/
|
|
127
|
+
export function adaptLightness(lightness, surface) {
|
|
128
|
+
const distance = Math.abs(REFERENCE_SURFACE - lightness);
|
|
129
|
+
return clamp01(surface > SURFACE_MIDPOINT ? REFERENCE_SURFACE - distance : surface + distance);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Barwa przeniesiona na zadaną powierzchnię. Rusza wyłącznie jasność — odcień i nasycenie zostają,
|
|
133
|
+
* więc barwa marki nie zmienia się w inną barwę, tylko dobiera jasność do tła.
|
|
134
|
+
*/
|
|
135
|
+
export function adaptToSurface(rgb, surface) {
|
|
136
|
+
const lab = srgbToOklab(rgb);
|
|
137
|
+
return oklabToSrgb({ ...lab, l: adaptLightness(lab.l, srgbToOklab(surface).l) });
|
|
138
|
+
}
|
package/dist/oklch.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKLCH — ten sam model co OKLab, tylko we współrzędnych biegunowych: jasność, nasycenie, kąt
|
|
3
|
+
* odcienia. W tej postaci tokeny biblioteki są zapisane i w tej postaci mają wyjść z edytora,
|
|
4
|
+
* więc tu żyje parser i serializator zapisu `oklch()`.
|
|
5
|
+
*
|
|
6
|
+
* Dlaczego własny parser, skoro jest `readCssColor`: tamten maluje piksel na canvasie, więc
|
|
7
|
+
* wymaga DOM i gubi wszystko poza 8 bitami na kanał. Generator palety i emitery motywu pracują
|
|
8
|
+
* w Node (build, testy) i muszą trzymać pełną precyzję — inaczej rampa liczona z ziarna wracałaby
|
|
9
|
+
* z zaokrągleniem do sRGB przy każdym kroku.
|
|
10
|
+
*/
|
|
11
|
+
import type { KptOklab } from './oklab.ts';
|
|
12
|
+
import type { KptRgb } from './srgb.ts';
|
|
13
|
+
/** Kolor w OKLCH: jasność 0–1, nasycenie od 0, kąt odcienia w stopniach 0–360, alfa 0–1. */
|
|
14
|
+
export interface KptOklch {
|
|
15
|
+
l: number;
|
|
16
|
+
c: number;
|
|
17
|
+
h: number;
|
|
18
|
+
alpha?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function oklabToOklch(lab: KptOklab): KptOklch;
|
|
21
|
+
export declare function oklchToOklab(color: KptOklch): KptOklab;
|
|
22
|
+
export declare const srgbToOklch: (rgb: KptRgb) => KptOklch;
|
|
23
|
+
export declare const oklchToSrgb: (color: KptOklch) => KptRgb;
|
|
24
|
+
/** Czy barwa mieści się w sRGB. Liczone na składowych liniowych, przed przycięciem. */
|
|
25
|
+
export declare function isInGamut(color: KptOklch): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Barwa sprowadzona do gamutu sRGB przez obniżenie samego nasycenia.
|
|
28
|
+
*
|
|
29
|
+
* Jasność i odcień zostają nietknięte, bo to one niosą tożsamość barwy: przyciętej po składowych
|
|
30
|
+
* RGB barwie marki potrafi się przesunąć odcień o kilkanaście stopni, a to już jest inny kolor.
|
|
31
|
+
* Obniżenie nasycenia daje najbliższą barwę, którą ekran faktycznie potrafi pokazać.
|
|
32
|
+
*
|
|
33
|
+
* Połowienie przedziału, nie wzór: granica gamutu w OKLCH jest kawałkami gładka i nie ma
|
|
34
|
+
* zamkniętej postaci, więc dwadzieścia kroków jest i krótsze do napisania, i pewniejsze.
|
|
35
|
+
*/
|
|
36
|
+
export declare function clampToGamut(color: KptOklch): KptOklch;
|
|
37
|
+
/**
|
|
38
|
+
* Zapis `oklch(L C H)` albo `oklch(L C H / A)` na składowe. Zwraca `null` na wszystkim innym —
|
|
39
|
+
* w tym na `none`, `color-mix()` i na łańcuchu `var()`, bo tych bez DOM i bez kontekstu kaskady
|
|
40
|
+
* nie da się uczciwie rozwiązać, a zgadywanie skończyłoby się kolorem, którego nikt nie wpisał.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseOklch(value: string): KptOklch | null;
|
|
43
|
+
/**
|
|
44
|
+
* Składowe na zapis `oklch()` w formacie, w jakim tokeny są zapisane w źródłach: jasność
|
|
45
|
+
* i nasycenie na trzech miejscach, odcień całkowity, alfa tylko gdy mniejsza od 1.
|
|
46
|
+
*
|
|
47
|
+
* Trzy miejsca to nie ozdoba — przy dwóch sąsiednie stopnie rampy neutralnej zaczynają się
|
|
48
|
+
* zlewać w tę samą wartość.
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatOklch(color: KptOklch): string;
|
package/dist/oklch.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKLCH — ten sam model co OKLab, tylko we współrzędnych biegunowych: jasność, nasycenie, kąt
|
|
3
|
+
* odcienia. W tej postaci tokeny biblioteki są zapisane i w tej postaci mają wyjść z edytora,
|
|
4
|
+
* więc tu żyje parser i serializator zapisu `oklch()`.
|
|
5
|
+
*
|
|
6
|
+
* Dlaczego własny parser, skoro jest `readCssColor`: tamten maluje piksel na canvasie, więc
|
|
7
|
+
* wymaga DOM i gubi wszystko poza 8 bitami na kanał. Generator palety i emitery motywu pracują
|
|
8
|
+
* w Node (build, testy) i muszą trzymać pełną precyzję — inaczej rampa liczona z ziarna wracałaby
|
|
9
|
+
* z zaokrągleniem do sRGB przy każdym kroku.
|
|
10
|
+
*/
|
|
11
|
+
import { oklabToLinearSrgb, srgbToOklab, oklabToSrgb, TAU } from "./oklab.js";
|
|
12
|
+
const DEG = 360 / TAU;
|
|
13
|
+
export function oklabToOklch(lab) {
|
|
14
|
+
const c = Math.hypot(lab.a, lab.b);
|
|
15
|
+
// Szarość nie ma kąta — `atan2` zwróciłby dla niej wartość przypadkową, a stąd bierze się
|
|
16
|
+
// szarpnięcie barwą przy przejściu przez oś achromatyczną. Zero jest tu umowne, ale stabilne.
|
|
17
|
+
const h = c === 0 ? 0 : (Math.atan2(lab.b, lab.a) * DEG + 360) % 360;
|
|
18
|
+
return { l: lab.l, c, h };
|
|
19
|
+
}
|
|
20
|
+
export function oklchToOklab(color) {
|
|
21
|
+
const radians = (color.h / DEG) % TAU;
|
|
22
|
+
return { l: color.l, a: Math.cos(radians) * color.c, b: Math.sin(radians) * color.c };
|
|
23
|
+
}
|
|
24
|
+
export const srgbToOklch = (rgb) => oklabToOklch(srgbToOklab(rgb));
|
|
25
|
+
export const oklchToSrgb = (color) => oklabToSrgb(oklchToOklab(color));
|
|
26
|
+
/** Margines na błąd zaokrągleń — bez niego barwa dokładnie na krawędzi gamutu bywa odrzucana. */
|
|
27
|
+
const GAMUT_EPSILON = 1e-4;
|
|
28
|
+
/** Czy barwa mieści się w sRGB. Liczone na składowych liniowych, przed przycięciem. */
|
|
29
|
+
export function isInGamut(color) {
|
|
30
|
+
const { r, g, b } = oklabToLinearSrgb(oklchToOklab(color));
|
|
31
|
+
const min = Math.min(r, g, b);
|
|
32
|
+
const max = Math.max(r, g, b);
|
|
33
|
+
return min >= -GAMUT_EPSILON && max <= 1 + GAMUT_EPSILON;
|
|
34
|
+
}
|
|
35
|
+
/** Poniżej tej różnicy nasycenia dalsze połowienie przedziału nic już nie zmienia w wyniku. */
|
|
36
|
+
const GAMUT_PRECISION = 1e-4;
|
|
37
|
+
/**
|
|
38
|
+
* Barwa sprowadzona do gamutu sRGB przez obniżenie samego nasycenia.
|
|
39
|
+
*
|
|
40
|
+
* Jasność i odcień zostają nietknięte, bo to one niosą tożsamość barwy: przyciętej po składowych
|
|
41
|
+
* RGB barwie marki potrafi się przesunąć odcień o kilkanaście stopni, a to już jest inny kolor.
|
|
42
|
+
* Obniżenie nasycenia daje najbliższą barwę, którą ekran faktycznie potrafi pokazać.
|
|
43
|
+
*
|
|
44
|
+
* Połowienie przedziału, nie wzór: granica gamutu w OKLCH jest kawałkami gładka i nie ma
|
|
45
|
+
* zamkniętej postaci, więc dwadzieścia kroków jest i krótsze do napisania, i pewniejsze.
|
|
46
|
+
*/
|
|
47
|
+
export function clampToGamut(color) {
|
|
48
|
+
if (isInGamut(color))
|
|
49
|
+
return color;
|
|
50
|
+
let low = 0;
|
|
51
|
+
let high = color.c;
|
|
52
|
+
while (high - low > GAMUT_PRECISION) {
|
|
53
|
+
const mid = (low + high) / 2;
|
|
54
|
+
if (isInGamut({ ...color, c: mid }))
|
|
55
|
+
low = mid;
|
|
56
|
+
else
|
|
57
|
+
high = mid;
|
|
58
|
+
}
|
|
59
|
+
return { ...color, c: low };
|
|
60
|
+
}
|
|
61
|
+
const NUMBER = String.raw `[+-]?(?:\d*\.\d+|\d+\.?)(?:e[+-]?\d+)?`;
|
|
62
|
+
const OKLCH_RE = new RegExp(String.raw `^oklch\(\s*(${NUMBER})(%?)\s+(${NUMBER})(%?)\s+(${NUMBER})(?:deg)?` +
|
|
63
|
+
String.raw `(?:\s*/\s*(${NUMBER})(%?))?\s*\)$`, 'i');
|
|
64
|
+
/** Procent w miejscu liczby: jasność i alfa liczą 100% = 1, nasycenie 100% = 0.4 (jak w CSS). */
|
|
65
|
+
const scaled = (value, percent, fullScale) => percent ? (Number.parseFloat(value) / 100) * fullScale : Number.parseFloat(value);
|
|
66
|
+
/**
|
|
67
|
+
* Zapis `oklch(L C H)` albo `oklch(L C H / A)` na składowe. Zwraca `null` na wszystkim innym —
|
|
68
|
+
* w tym na `none`, `color-mix()` i na łańcuchu `var()`, bo tych bez DOM i bez kontekstu kaskady
|
|
69
|
+
* nie da się uczciwie rozwiązać, a zgadywanie skończyłoby się kolorem, którego nikt nie wpisał.
|
|
70
|
+
*/
|
|
71
|
+
export function parseOklch(value) {
|
|
72
|
+
const match = OKLCH_RE.exec(value.trim());
|
|
73
|
+
if (!match)
|
|
74
|
+
return null;
|
|
75
|
+
const [, l, lPercent, c, cPercent, h, alpha, alphaPercent] = match;
|
|
76
|
+
const parsed = {
|
|
77
|
+
l: scaled(l, lPercent, 1),
|
|
78
|
+
c: scaled(c, cPercent, 0.4),
|
|
79
|
+
h: ((Number.parseFloat(h) % 360) + 360) % 360,
|
|
80
|
+
};
|
|
81
|
+
if (alpha !== undefined)
|
|
82
|
+
parsed.alpha = scaled(alpha, alphaPercent, 1);
|
|
83
|
+
return Number.isFinite(parsed.l) && Number.isFinite(parsed.c) && Number.isFinite(parsed.h) ? parsed : null;
|
|
84
|
+
}
|
|
85
|
+
/** Ucina zera na końcu części dziesiętnej: `0.500` → `0.5`, `1.000` → `1`. */
|
|
86
|
+
const trim = (value, digits) => value.toFixed(digits).replace(/\.?0+$/, '') || '0';
|
|
87
|
+
/**
|
|
88
|
+
* Składowe na zapis `oklch()` w formacie, w jakim tokeny są zapisane w źródłach: jasność
|
|
89
|
+
* i nasycenie na trzech miejscach, odcień całkowity, alfa tylko gdy mniejsza od 1.
|
|
90
|
+
*
|
|
91
|
+
* Trzy miejsca to nie ozdoba — przy dwóch sąsiednie stopnie rampy neutralnej zaczynają się
|
|
92
|
+
* zlewać w tę samą wartość.
|
|
93
|
+
*/
|
|
94
|
+
export function formatOklch(color) {
|
|
95
|
+
const chroma = color.c.toFixed(3);
|
|
96
|
+
// Barwa, której nasycenie zaokrągla się do zera, jest na papierze achromatyczna — a odcień
|
|
97
|
+
// achromatycznej barwy nie znaczy nic. Biel policzona z `#ffffff` niesie chromę rzędu 1e-8
|
|
98
|
+
// i wraz z nią jakiś kąt, więc bez tego dwie identyczne biele z różnych źródeł zapisałyby się
|
|
99
|
+
// różnie (`oklch(1.000 0.000 90)` i `oklch(1.000 0.000 0)`) i robiły fałszywy diff w motywie.
|
|
100
|
+
const hue = chroma === '0.000' ? 0 : Math.round(color.h);
|
|
101
|
+
const base = `${color.l.toFixed(3)} ${chroma} ${hue}`;
|
|
102
|
+
const alpha = color.alpha;
|
|
103
|
+
return alpha === undefined || alpha >= 1 ? `oklch(${base})` : `oklch(${base} / ${trim(alpha, 4)})`;
|
|
104
|
+
}
|
package/dist/srgb.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sRGB: składowe, zapis heksadecymalny i przejście przez gammę.
|
|
3
|
+
*
|
|
4
|
+
* To najniższa warstwa paczki — nic tu nie wie o OKLab ani o kontraście. Reszta modułów liczy
|
|
5
|
+
* na liniowych składowych (`toLinear`), bo zarówno macierze OKLab, jak i rachunek luminancji
|
|
6
|
+
* wymagają światła, a nie wartości po gammie.
|
|
7
|
+
*/
|
|
8
|
+
/** Kolor w sRGB, składowe 0–255. */
|
|
9
|
+
export interface KptRgb {
|
|
10
|
+
r: number;
|
|
11
|
+
g: number;
|
|
12
|
+
b: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const clamp255: (value: number) => number;
|
|
15
|
+
/** Gamma sRGB → liniowo. */
|
|
16
|
+
export declare const toLinear: (channel: number) => number;
|
|
17
|
+
/** Liniowo → gamma sRGB. */
|
|
18
|
+
export declare const toGamma: (value: number) => number;
|
|
19
|
+
/**
|
|
20
|
+
* Zapis heksadecymalny (`#rgb` albo `#rrggbb`, z krzyżykiem lub bez) na składowe.
|
|
21
|
+
* Zwraca `null` na wszystkim innym — kolor przychodzi z wejścia komponentu albo z konfiguratora,
|
|
22
|
+
* więc śmieci są normalnym przypadkiem, a nie wyjątkiem.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseHexColor(hex: string): KptRgb | null;
|
|
25
|
+
/**
|
|
26
|
+
* Dowolny zapis koloru CSS na składowe — także `oklch()`, `color-mix()` czy nazwa własna.
|
|
27
|
+
*
|
|
28
|
+
* Tokeny biblioteki są zapisane w OKLCH, a barwy trzeba czasem wymieszać, nie tylko podać dalej.
|
|
29
|
+
* Zamiast pisać własny parser każdej składni CSS, malujemy kolor na canvasie 1×1 i odczytujemy
|
|
30
|
+
* piksel: przeglądarka rozumie każdy zapis, który sama akceptuje, i nigdy nie rozejdzie się
|
|
31
|
+
* z tym, co widać na stronie.
|
|
32
|
+
*
|
|
33
|
+
* Zapis nieprawidłowy daje `null`, tak samo jak środowisko bez DOM — a bez DOM jest cały Node,
|
|
34
|
+
* więc emitery i testy muszą sobie radzić bez tej funkcji (od OKLCH jest `parseOklch`).
|
|
35
|
+
* `fillStyle` odrzuca nieprawidłową wartość po cichu, zostawiając poprzednią, więc sam odczyt
|
|
36
|
+
* piksela nie odróżniłby śmiecia od legalnej czerni — stąd dwa różne wartownicy: przy wartości
|
|
37
|
+
* nieprawidłowej każdy z nich zostaje na swoim miejscu i oba przebiegi dają inny wynik.
|
|
38
|
+
* Getter zwraca kolor już zserializowany, więc bramka nie potrzebuje odczytu piksela.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readCssColor(value: string): KptRgb | null;
|
|
41
|
+
/** Składowe na `#rrggbb`. */
|
|
42
|
+
export declare function toHexColor(rgb: KptRgb): string;
|
package/dist/srgb.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sRGB: składowe, zapis heksadecymalny i przejście przez gammę.
|
|
3
|
+
*
|
|
4
|
+
* To najniższa warstwa paczki — nic tu nie wie o OKLab ani o kontraście. Reszta modułów liczy
|
|
5
|
+
* na liniowych składowych (`toLinear`), bo zarówno macierze OKLab, jak i rachunek luminancji
|
|
6
|
+
* wymagają światła, a nie wartości po gammie.
|
|
7
|
+
*/
|
|
8
|
+
export const clamp255 = (value) => (value < 0 ? 0 : value > 255 ? 255 : Math.round(value));
|
|
9
|
+
/** Gamma sRGB → liniowo. */
|
|
10
|
+
export const toLinear = (channel) => {
|
|
11
|
+
const value = channel / 255;
|
|
12
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
13
|
+
};
|
|
14
|
+
/** Liniowo → gamma sRGB. */
|
|
15
|
+
export const toGamma = (value) => {
|
|
16
|
+
const encoded = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
|
|
17
|
+
return clamp255(encoded * 255);
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Zapis heksadecymalny (`#rgb` albo `#rrggbb`, z krzyżykiem lub bez) na składowe.
|
|
21
|
+
* Zwraca `null` na wszystkim innym — kolor przychodzi z wejścia komponentu albo z konfiguratora,
|
|
22
|
+
* więc śmieci są normalnym przypadkiem, a nie wyjątkiem.
|
|
23
|
+
*/
|
|
24
|
+
export function parseHexColor(hex) {
|
|
25
|
+
const value = hex.trim().replace(/^#/, '');
|
|
26
|
+
const full = value.length === 3 ? value.replace(/./g, (char) => char + char) : value;
|
|
27
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
r: Number.parseInt(full.slice(0, 2), 16),
|
|
31
|
+
g: Number.parseInt(full.slice(2, 4), 16),
|
|
32
|
+
b: Number.parseInt(full.slice(4, 6), 16),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Leniwy kontekst 1×1 — służy wyłącznie do pytania przeglądarki o znaczenie zapisu koloru. */
|
|
36
|
+
let probe;
|
|
37
|
+
/**
|
|
38
|
+
* Dowolny zapis koloru CSS na składowe — także `oklch()`, `color-mix()` czy nazwa własna.
|
|
39
|
+
*
|
|
40
|
+
* Tokeny biblioteki są zapisane w OKLCH, a barwy trzeba czasem wymieszać, nie tylko podać dalej.
|
|
41
|
+
* Zamiast pisać własny parser każdej składni CSS, malujemy kolor na canvasie 1×1 i odczytujemy
|
|
42
|
+
* piksel: przeglądarka rozumie każdy zapis, który sama akceptuje, i nigdy nie rozejdzie się
|
|
43
|
+
* z tym, co widać na stronie.
|
|
44
|
+
*
|
|
45
|
+
* Zapis nieprawidłowy daje `null`, tak samo jak środowisko bez DOM — a bez DOM jest cały Node,
|
|
46
|
+
* więc emitery i testy muszą sobie radzić bez tej funkcji (od OKLCH jest `parseOklch`).
|
|
47
|
+
* `fillStyle` odrzuca nieprawidłową wartość po cichu, zostawiając poprzednią, więc sam odczyt
|
|
48
|
+
* piksela nie odróżniłby śmiecia od legalnej czerni — stąd dwa różne wartownicy: przy wartości
|
|
49
|
+
* nieprawidłowej każdy z nich zostaje na swoim miejscu i oba przebiegi dają inny wynik.
|
|
50
|
+
* Getter zwraca kolor już zserializowany, więc bramka nie potrzebuje odczytu piksela.
|
|
51
|
+
*/
|
|
52
|
+
export function readCssColor(value) {
|
|
53
|
+
const direct = parseHexColor(value);
|
|
54
|
+
if (direct)
|
|
55
|
+
return direct;
|
|
56
|
+
if (typeof document === 'undefined')
|
|
57
|
+
return null;
|
|
58
|
+
if (probe === undefined) {
|
|
59
|
+
const canvas = document.createElement('canvas');
|
|
60
|
+
canvas.width = 1;
|
|
61
|
+
canvas.height = 1;
|
|
62
|
+
probe = canvas.getContext('2d', { willReadFrequently: true });
|
|
63
|
+
}
|
|
64
|
+
if (!probe)
|
|
65
|
+
return null;
|
|
66
|
+
probe.fillStyle = '#000000';
|
|
67
|
+
probe.fillStyle = value;
|
|
68
|
+
const first = probe.fillStyle;
|
|
69
|
+
probe.fillStyle = '#ffffff';
|
|
70
|
+
probe.fillStyle = value;
|
|
71
|
+
if (probe.fillStyle !== first)
|
|
72
|
+
return null;
|
|
73
|
+
// Sonda musi zaczynać od przezroczystości: barwa z kanałem alfa nałożyłaby się na poprzedni
|
|
74
|
+
// odczyt i wyszłaby mieszanka dwóch niezwiązanych ze sobą wywołań.
|
|
75
|
+
probe.clearRect(0, 0, 1, 1);
|
|
76
|
+
probe.fillRect(0, 0, 1, 1);
|
|
77
|
+
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
|
78
|
+
return { r, g, b };
|
|
79
|
+
}
|
|
80
|
+
/** Składowe na `#rrggbb`. */
|
|
81
|
+
export function toHexColor(rgb) {
|
|
82
|
+
const part = (value) => clamp255(value).toString(16).padStart(2, '0');
|
|
83
|
+
return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`;
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@konce-pt/color",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Colour maths for Koncept UI — sRGB, OKLab and OKLCH conversions, hue-aware mixing, gamut mapping, WCAG and APCA contrast. Framework-free TypeScript, zero runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "konce.pt",
|
|
7
|
+
"homepage": "https://ui.konce.pt/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://gitlab.com/konce-pt/koncept-ui.git",
|
|
11
|
+
"directory": "packages/color"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://gitlab.com/konce-pt/koncept-ui/-/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"color",
|
|
18
|
+
"colour",
|
|
19
|
+
"oklch",
|
|
20
|
+
"oklab",
|
|
21
|
+
"contrast",
|
|
22
|
+
"wcag",
|
|
23
|
+
"apca",
|
|
24
|
+
"gamut",
|
|
25
|
+
"design-system",
|
|
26
|
+
"koncept-ui",
|
|
27
|
+
"kpt"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"module": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "~6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"test": "node --test \"src/**/*.test.ts\"",
|
|
48
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
|
|
49
|
+
}
|
|
50
|
+
}
|