@konce-pt/backdrop 0.8.5

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 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,84 @@
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/backdrop
6
+
7
+ Animowane tło [Koncept UI](https://gitlab.com/konce-pt/koncept-ui) to dekoracyjna warstwa pod
8
+ sekcję — rysowana na canvasie zamiast wczytywana jako obraz, więc nie kosztuje transferu, skaluje
9
+ się bez rozdzielczości i bierze kolory z tokenów motywu.
10
+
11
+ Ta paczka jest jego rdzeniem: układ siatki i rampa gęstości efektu **signal-grid**, płynąca wstęga
12
+ i rampa barw efektu **wave**, oraz renderery canvas dla obu. Czysty TypeScript, **zero zależności
13
+ runtime**, bez wiązania z frameworkiem.
14
+
15
+ Zwykle nie instalujesz go samodzielnie: używa go `KptBackdrop` z `@konce-pt/angular`
16
+ i z `@konce-pt/react`. Sięgnij po niego wprost, gdy rysujesz ten sam efekt własnym rendererem.
17
+
18
+ ```bash
19
+ npm i @konce-pt/backdrop
20
+ ```
21
+
22
+ ```ts
23
+ import { createBackdropRenderer } from '@konce-pt/backdrop';
24
+
25
+ // Renderer żyje tyle, co jeden zestaw ustawień — zmiana wejść buduje nowy.
26
+ const renderer = createBackdropRenderer({
27
+ effect: 'wave',
28
+ colors: ['#5B8AF2', '#A86DF2', '#F25B9E'],
29
+ rotation: 12,
30
+ });
31
+
32
+ renderer.resize(1200, 480); // przy starcie i przy zmianie rozmiaru
33
+ renderer.draw(context, 3.4); // klatka dla 3,4 sekundy animacji
34
+ ```
35
+
36
+ ## Dwa efekty, jedna zasada
37
+
38
+ **signal-grid** bierze gradient z tego, **ile** kwadratów się pali w danej odległości od kotwicy —
39
+ nie przyciemnia gotowej, równomiernej siatki. **wave** opada od linii środkowej krzywą Gaussa,
40
+ więc w całym kadrze nie ma ani jednej twardej krawędzi; to właśnie odróżnia prąd światła od paska.
41
+
42
+ W obu wypadkach losowania wyprowadzają się z pozycji, a nie z zapamiętanego stanu, więc zmiana
43
+ rozmiaru okna zostawia obraz rozpoznawalnym zamiast tasować go od nowa.
44
+
45
+ ## Barwy mieszają się dookoła koła
46
+
47
+ Rampa wstęgi jest zamknięta w pętlę i mieszana po biegunowej postaci OKLab. Interpolacja sRGB
48
+ miesza wartości po gammie i wychodzi błotem; nawet prostokątny OKLab przecina oś achromatyczną dla
49
+ przeciwnych odcieni, więc w połowie drogi z niebieskiego w żółty zostaje szarość — dokładnie tam,
50
+ gdzie prąd świeci najmocniej.
51
+
52
+ ## Wstęga liczy się w małym buforze
53
+
54
+ Efekt nie ma detalu, tylko miękkość. Liczymy go więc w buforze o dłuższym boku 160 px i skalujemy
55
+ w górę — rozmycie robi za darmo interpolacja przeglądarki. Kilka tysięcy pikseli na klatkę
56
+ obsługuje kadr dowolnej wielkości.
57
+
58
+ ## API
59
+
60
+ - **Renderery** — `createBackdropRenderer`, `createSignalGridRenderer`, `createWaveRenderer`,
61
+ `resolveBackdropColors`, typy `KptBackdropRenderer`, `KptBackdropRenderOptions`,
62
+ `KptBackdropColors`
63
+ - **Siatka** — `buildSignalGrid`, `signalGridDensity`, `signalGridAlpha`, typy
64
+ `KptSignalGridOptions`, `KptSignalGridFrame`, `KptSignalGridCell`
65
+ - **Wstęga** — `resolveWave`, `waveIntensity`, `waveRamp`, `waveBuffer`, typy `KptWaveOptions`,
66
+ `KptWaveSettings`, `KptWaveBuffer`, `KptWaveHarmonic`
67
+ - **Barwy** — `mixOklch`, `shiftHue`, `readCssColor`, `parseHexColor`, `toHexColor`,
68
+ `srgbToOklab`, `oklabToSrgb`, typy `KptRgb`, `KptOklab`
69
+ - **Losowanie** — `kptHash01`
70
+ - **Stałe** — `KPT_SIGNAL_GRID_*`, `KPT_WAVE_*`, `KPT_BACKDROP_MAX_DPR`, `KPT_BACKDROP_MAX_STEP`
71
+ - **Model** — typy `KptBackdropAnchor`, `KptBackdropEffect`
72
+
73
+ Pełny opis: [`llms.txt`](https://ui.konce.pt/llms/backdrop/llms.txt) (EN)
74
+ i [`llms-pl.txt`](https://ui.konce.pt/llms/backdrop/llms-pl.txt) (PL).
75
+
76
+ ## Testy
77
+
78
+ ```bash
79
+ pnpm --filter @konce-pt/backdrop exec node --test "src/**/*.test.ts"
80
+ ```
81
+
82
+ ## Licencja
83
+
84
+ MIT © [konce.pt](https://konce.pt)
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Barwy wstęgi. Mieszanie idzie po współrzędnych biegunowych OKLab (czyli OKLCH), nie po
3
+ * składowych sRGB ani nawet nie po prostokątnym OKLab.
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 — a takie są w rampie
7
+ * wstęgi — i tak przecina oś achromatyczną: w połowie przejścia z niebieskiego w żółty zostaje
8
+ * szarość dokładnie tam, gdzie wstęga świeci najmocniej. Dopiero rozbicie na jasność, nasycenie
9
+ * i kąt odcienia pozwala obejść koło dookoła z zachowanym nasyceniem.
10
+ *
11
+ * Konwersje wg modelu OKLab Björna Ottossona.
12
+ */
13
+ /** Kolor w sRGB, składowe 0–255. */
14
+ export interface KptRgb {
15
+ r: number;
16
+ g: number;
17
+ b: number;
18
+ }
19
+ /** Kolor w OKLab: jasność `l` oraz para składowych chromatycznych. */
20
+ export interface KptOklab {
21
+ l: number;
22
+ a: number;
23
+ b: number;
24
+ }
25
+ /**
26
+ * Zapis heksadecymalny (`#rgb` albo `#rrggbb`, z krzyżykiem lub bez) na składowe.
27
+ * Zwraca `null` na wszystkim innym — kolor przychodzi z wejścia komponentu albo z konfiguratora,
28
+ * więc śmieci są normalnym przypadkiem, a nie wyjątkiem.
29
+ */
30
+ export declare function parseHexColor(hex: string): KptRgb | null;
31
+ /**
32
+ * Dowolny zapis koloru CSS na składowe — także `oklch()`, `color-mix()` czy nazwa własna.
33
+ *
34
+ * Tokeny biblioteki są zapisane w OKLCH, a barwy wstęgi trzeba wymieszać, nie tylko podać dalej.
35
+ * Zamiast pisać własny parser każdej składni CSS, malujemy kolor na canvasie 1×1 i odczytujemy
36
+ * piksel: przeglądarka rozumie każdy zapis, który sama akceptuje, i nigdy nie rozejdzie się
37
+ * z tym, co widać na stronie.
38
+ *
39
+ * Zapis nieprawidłowy daje `null`, tak samo jak środowisko bez DOM. `fillStyle` odrzuca taką
40
+ * wartość po cichu, zostawiając poprzednią, więc sam odczyt piksela nie odróżniłby śmiecia od
41
+ * legalnej czerni — stąd dwa różne wartowniki: przy wartości nieprawidłowej każdy z nich zostaje
42
+ * na swoim miejscu i oba przebiegi dają inny wynik. Getter zwraca kolor już zserializowany,
43
+ * więc bramka nie potrzebuje odczytu piksela.
44
+ */
45
+ export declare function readCssColor(value: string): KptRgb | null;
46
+ /** Składowe na `#rrggbb`. */
47
+ export declare function toHexColor(rgb: KptRgb): string;
48
+ export declare function srgbToOklab(rgb: KptRgb): KptOklab;
49
+ export declare function oklabToSrgb(lab: KptOklab): KptRgb;
50
+ /**
51
+ * Obrót odcienia o zadany kąt (stopnie) przy zachowanej jasności i nasyceniu.
52
+ *
53
+ * Stąd biorą się domyślne barwy wstęgi: paleta biblioteki jest neutralna, więc zamiast sięgać
54
+ * po tokeny statusowe (`info`, `success`) do celów dekoracyjnych, dwie towarzyszące barwy
55
+ * wyprowadzamy z koloru wiodącego. Wstęga zostaje w kolorach aplikacji niezależnie od motywu.
56
+ */
57
+ export declare function shiftHue(rgb: KptRgb, degrees: number): KptRgb;
58
+ /**
59
+ * Kolor na zamkniętej rampie `colors` w miejscu `position`.
60
+ *
61
+ * Rampa jest cykliczna: ostatni przystanek wraca do pierwszego, bo barwy przesuwają się wzdłuż
62
+ * wstęgi bez końca i szew w środku kadru byłby widoczny. `position` poza zakresem 0–1 zawija się,
63
+ * więc wywołujący nie musi pilnować rosnącego czasu.
64
+ *
65
+ * Odcień idzie krótszym łukiem koła. Przystanek bez nasycenia przejmuje odcień sąsiada — inaczej
66
+ * przejście do szarości i z powrotem szarpnęłoby barwą, bo `atan2` na zerowym nasyceniu zwraca
67
+ * kąt przypadkowy.
68
+ */
69
+ export declare function mixOklch(colors: readonly KptRgb[], position: number): KptRgb;
70
+ /**
71
+ * Jasność przeniesiona na powierzchnię bieżącego motywu z zachowaniem odległości od niej.
72
+ *
73
+ * Barwa zapisana na biel odsuwa się od niej o `d`; na ciemnej powierzchni ma się odsunąć o to samo
74
+ * `d`, tyle że w drugą stronę — bo tam właśnie jest miejsce. Stąd czerń na bieli staje się bielą
75
+ * na ciemnym tle, a biel staje się dokładnie powierzchnią. Barwa w połowie skali zostaje mniej
76
+ * więcej tam, gdzie była, i dalej odcina się od tła.
77
+ *
78
+ * Na jasnej powierzchni wychodzi tożsamość: `1 - (1 - L)` to `L`.
79
+ */
80
+ export declare function adaptLightness(lightness: number, surface: number): number;
81
+ /**
82
+ * Barwa przeniesiona na zadaną powierzchnię. Rusza wyłącznie jasność — odcień i nasycenie zostają,
83
+ * więc barwa marki nie zmienia się w inną barwę, tylko dobiera jasność do tła.
84
+ */
85
+ export declare function adaptToSurface(rgb: KptRgb, surface: KptRgb): KptRgb;
86
+ /**
87
+ * Jasność względna wg WCAG (0 = czerń, 1 = biel).
88
+ *
89
+ * To nie to samo co jasność OKLab: OKLab opisuje, jak jasno barwa *wygląda*, a ta liczba mówi,
90
+ * ile z niej wychodzi światła — i tylko na niej stoi rachunek kontrastu, którym rozstrzygamy,
91
+ * czy na danym tle czytelniejszy jest napis ciemny czy jasny.
92
+ */
93
+ export declare function relativeLuminance(rgb: KptRgb): number;
94
+ /**
95
+ * Czy na tej powierzchni czytelniejsza jest treść ciemna.
96
+ *
97
+ * Sekcja z własnym tłem przestaje być powierzchnią strony, więc `--kpt-color-on-surface` opisuje
98
+ * już nie to tło, tylko sąsiada. Bez tego rachunku treść w sekcji z ciemnym tłem w motywie jasnym
99
+ * wychodzi czarna na czarnym.
100
+ */
101
+ export declare function prefersDarkContent(surface: KptRgb): boolean;
package/dist/color.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Barwy wstęgi. Mieszanie idzie po współrzędnych biegunowych OKLab (czyli OKLCH), nie po
3
+ * składowych sRGB ani nawet nie po prostokątnym OKLab.
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 — a takie są w rampie
7
+ * wstęgi — i tak przecina oś achromatyczną: w połowie przejścia z niebieskiego w żółty zostaje
8
+ * szarość dokładnie tam, gdzie wstęga świeci najmocniej. Dopiero rozbicie na jasność, nasycenie
9
+ * i kąt odcienia pozwala obejść koło dookoła z zachowanym nasyceniem.
10
+ *
11
+ * Konwersje wg modelu OKLab Björna Ottossona.
12
+ */
13
+ const TAU = Math.PI * 2;
14
+ const clamp255 = (value) => (value < 0 ? 0 : value > 255 ? 255 : Math.round(value));
15
+ /** Gamma sRGB → liniowo. */
16
+ const toLinear = (channel) => {
17
+ const value = channel / 255;
18
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
19
+ };
20
+ /** Liniowo → gamma sRGB. */
21
+ const toGamma = (value) => {
22
+ const encoded = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
23
+ return clamp255(encoded * 255);
24
+ };
25
+ /**
26
+ * Zapis heksadecymalny (`#rgb` albo `#rrggbb`, z krzyżykiem lub bez) na składowe.
27
+ * Zwraca `null` na wszystkim innym — kolor przychodzi z wejścia komponentu albo z konfiguratora,
28
+ * więc śmieci są normalnym przypadkiem, a nie wyjątkiem.
29
+ */
30
+ export function parseHexColor(hex) {
31
+ const value = hex.trim().replace(/^#/, '');
32
+ const full = value.length === 3 ? value.replace(/./g, (char) => char + char) : value;
33
+ if (!/^[0-9a-f]{6}$/i.test(full))
34
+ return null;
35
+ return {
36
+ r: Number.parseInt(full.slice(0, 2), 16),
37
+ g: Number.parseInt(full.slice(2, 4), 16),
38
+ b: Number.parseInt(full.slice(4, 6), 16),
39
+ };
40
+ }
41
+ /** Leniwy kontekst 1×1 — służy wyłącznie do pytania przeglądarki o znaczenie zapisu koloru. */
42
+ let probe;
43
+ /**
44
+ * Dowolny zapis koloru CSS na składowe — także `oklch()`, `color-mix()` czy nazwa własna.
45
+ *
46
+ * Tokeny biblioteki są zapisane w OKLCH, a barwy wstęgi trzeba wymieszać, nie tylko podać dalej.
47
+ * Zamiast pisać własny parser każdej składni CSS, malujemy kolor na canvasie 1×1 i odczytujemy
48
+ * piksel: przeglądarka rozumie każdy zapis, który sama akceptuje, i nigdy nie rozejdzie się
49
+ * z tym, co widać na stronie.
50
+ *
51
+ * Zapis nieprawidłowy daje `null`, tak samo jak środowisko bez DOM. `fillStyle` odrzuca taką
52
+ * wartość po cichu, zostawiając poprzednią, więc sam odczyt piksela nie odróżniłby śmiecia od
53
+ * legalnej czerni — stąd dwa różne wartowniki: przy wartości nieprawidłowej każdy z nich zostaje
54
+ * na swoim miejscu i oba przebiegi dają inny wynik. Getter zwraca kolor już zserializowany,
55
+ * więc bramka nie potrzebuje odczytu piksela.
56
+ */
57
+ export function readCssColor(value) {
58
+ const direct = parseHexColor(value);
59
+ if (direct)
60
+ return direct;
61
+ if (typeof document === 'undefined')
62
+ return null;
63
+ if (probe === undefined) {
64
+ const canvas = document.createElement('canvas');
65
+ canvas.width = 1;
66
+ canvas.height = 1;
67
+ probe = canvas.getContext('2d', { willReadFrequently: true });
68
+ }
69
+ if (!probe)
70
+ return null;
71
+ probe.fillStyle = '#000000';
72
+ probe.fillStyle = value;
73
+ const first = probe.fillStyle;
74
+ probe.fillStyle = '#ffffff';
75
+ probe.fillStyle = value;
76
+ if (probe.fillStyle !== first)
77
+ return null;
78
+ // Sonda musi zaczynać od przezroczystości: barwa z kanałem alfa nałożyłaby się na poprzedni
79
+ // odczyt i wyszłaby mieszanka dwóch niezwiązanych ze sobą wywołań.
80
+ probe.clearRect(0, 0, 1, 1);
81
+ probe.fillRect(0, 0, 1, 1);
82
+ const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
83
+ return { r, g, b };
84
+ }
85
+ /** Składowe na `#rrggbb`. */
86
+ export function toHexColor(rgb) {
87
+ const part = (value) => clamp255(value).toString(16).padStart(2, '0');
88
+ return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`;
89
+ }
90
+ export function srgbToOklab(rgb) {
91
+ const r = toLinear(rgb.r);
92
+ const g = toLinear(rgb.g);
93
+ const b = toLinear(rgb.b);
94
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
95
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
96
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
97
+ return {
98
+ l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
99
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
100
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
101
+ };
102
+ }
103
+ export function oklabToSrgb(lab) {
104
+ const l = (lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b) ** 3;
105
+ const m = (lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b) ** 3;
106
+ const s = (lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b) ** 3;
107
+ return {
108
+ r: toGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
109
+ g: toGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
110
+ b: toGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
111
+ };
112
+ }
113
+ /**
114
+ * Obrót odcienia o zadany kąt (stopnie) przy zachowanej jasności i nasyceniu.
115
+ *
116
+ * Stąd biorą się domyślne barwy wstęgi: paleta biblioteki jest neutralna, więc zamiast sięgać
117
+ * po tokeny statusowe (`info`, `success`) do celów dekoracyjnych, dwie towarzyszące barwy
118
+ * wyprowadzamy z koloru wiodącego. Wstęga zostaje w kolorach aplikacji niezależnie od motywu.
119
+ */
120
+ export function shiftHue(rgb, degrees) {
121
+ const lab = srgbToOklab(rgb);
122
+ const chroma = Math.hypot(lab.a, lab.b);
123
+ const hue = Math.atan2(lab.b, lab.a) + (degrees / 360) * TAU;
124
+ return oklabToSrgb({ l: lab.l, a: Math.cos(hue) * chroma, b: Math.sin(hue) * chroma });
125
+ }
126
+ /** Poniżej tego nasycenia odcień przestaje cokolwiek znaczyć — szarość nie ma kąta. */
127
+ const ACHROMATIC = 1e-4;
128
+ /**
129
+ * Kolor na zamkniętej rampie `colors` w miejscu `position`.
130
+ *
131
+ * Rampa jest cykliczna: ostatni przystanek wraca do pierwszego, bo barwy przesuwają się wzdłuż
132
+ * wstęgi bez końca i szew w środku kadru byłby widoczny. `position` poza zakresem 0–1 zawija się,
133
+ * więc wywołujący nie musi pilnować rosnącego czasu.
134
+ *
135
+ * Odcień idzie krótszym łukiem koła. Przystanek bez nasycenia przejmuje odcień sąsiada — inaczej
136
+ * przejście do szarości i z powrotem szarpnęłoby barwą, bo `atan2` na zerowym nasyceniu zwraca
137
+ * kąt przypadkowy.
138
+ */
139
+ export function mixOklch(colors, position) {
140
+ if (colors.length === 0)
141
+ return { r: 0, g: 0, b: 0 };
142
+ if (colors.length === 1)
143
+ return colors[0];
144
+ const wrapped = position - Math.floor(position);
145
+ const scaled = wrapped * colors.length;
146
+ const index = Math.min(Math.floor(scaled), colors.length - 1);
147
+ const blend = scaled - index;
148
+ const from = srgbToOklab(colors[index]);
149
+ const to = srgbToOklab(colors[(index + 1) % colors.length]);
150
+ const fromChroma = Math.hypot(from.a, from.b);
151
+ const toChroma = Math.hypot(to.a, to.b);
152
+ const chroma = fromChroma + (toChroma - fromChroma) * blend;
153
+ let hue;
154
+ if (fromChroma < ACHROMATIC && toChroma < ACHROMATIC) {
155
+ hue = 0;
156
+ }
157
+ else if (fromChroma < ACHROMATIC) {
158
+ hue = Math.atan2(to.b, to.a);
159
+ }
160
+ else if (toChroma < ACHROMATIC) {
161
+ hue = Math.atan2(from.b, from.a);
162
+ }
163
+ else {
164
+ const start = Math.atan2(from.b, from.a);
165
+ const end = Math.atan2(to.b, to.a);
166
+ // Krótszy łuk: różnicę sprowadzamy do (-π, π], zamiast iść dookoła przez pół koła.
167
+ const delta = ((end - start + Math.PI * 3) % TAU) - Math.PI;
168
+ hue = start + delta * blend;
169
+ }
170
+ return oklabToSrgb({
171
+ l: from.l + (to.l - from.l) * blend,
172
+ a: Math.cos(hue) * chroma,
173
+ b: Math.sin(hue) * chroma,
174
+ });
175
+ }
176
+ /**
177
+ * Jasność powierzchni, względem której zapisano barwę. Biblioteka trzyma motyw jasny w `:root`,
178
+ * a ciemny jako nadpisanie, więc wartość wpisana do szablonu jest z założenia dobrana do jasnego —
179
+ * i to jest jedyne założenie, jakie robi adaptacja.
180
+ */
181
+ const REFERENCE_SURFACE = 1;
182
+ /** Powyżej tej jasności powierzchnię uznajemy za jasną, poniżej za ciemną. */
183
+ const SURFACE_MIDPOINT = 0.5;
184
+ const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
185
+ /**
186
+ * Jasność przeniesiona na powierzchnię bieżącego motywu z zachowaniem odległości od niej.
187
+ *
188
+ * Barwa zapisana na biel odsuwa się od niej o `d`; na ciemnej powierzchni ma się odsunąć o to samo
189
+ * `d`, tyle że w drugą stronę — bo tam właśnie jest miejsce. Stąd czerń na bieli staje się bielą
190
+ * na ciemnym tle, a biel staje się dokładnie powierzchnią. Barwa w połowie skali zostaje mniej
191
+ * więcej tam, gdzie była, i dalej odcina się od tła.
192
+ *
193
+ * Na jasnej powierzchni wychodzi tożsamość: `1 - (1 - L)` to `L`.
194
+ */
195
+ export function adaptLightness(lightness, surface) {
196
+ const distance = Math.abs(REFERENCE_SURFACE - lightness);
197
+ return clamp01(surface > SURFACE_MIDPOINT ? REFERENCE_SURFACE - distance : surface + distance);
198
+ }
199
+ /**
200
+ * Barwa przeniesiona na zadaną powierzchnię. Rusza wyłącznie jasność — odcień i nasycenie zostają,
201
+ * więc barwa marki nie zmienia się w inną barwę, tylko dobiera jasność do tła.
202
+ */
203
+ export function adaptToSurface(rgb, surface) {
204
+ const lab = srgbToOklab(rgb);
205
+ return oklabToSrgb({ ...lab, l: adaptLightness(lab.l, srgbToOklab(surface).l) });
206
+ }
207
+ /**
208
+ * Jasność względna wg WCAG (0 = czerń, 1 = biel).
209
+ *
210
+ * To nie to samo co jasność OKLab: OKLab opisuje, jak jasno barwa *wygląda*, a ta liczba mówi,
211
+ * ile z niej wychodzi światła — i tylko na niej stoi rachunek kontrastu, którym rozstrzygamy,
212
+ * czy na danym tle czytelniejszy jest napis ciemny czy jasny.
213
+ */
214
+ export function relativeLuminance(rgb) {
215
+ return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b);
216
+ }
217
+ /** Próg WCAG na wybór treści ciemnej albo jasnej — powyżej niego ciemna wygrywa kontrastem. */
218
+ const CONTENT_THRESHOLD = 0.179;
219
+ /**
220
+ * Czy na tej powierzchni czytelniejsza jest treść ciemna.
221
+ *
222
+ * Sekcja z własnym tłem przestaje być powierzchnią strony, więc `--kpt-color-on-surface` opisuje
223
+ * już nie to tło, tylko sąsiada. Bez tego rachunku treść w sekcji z ciemnym tłem w motywie jasnym
224
+ * wychodzi czarna na czarnym.
225
+ */
226
+ export function prefersDarkContent(surface) {
227
+ return relativeLuminance(surface) > CONTENT_THRESHOLD;
228
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Deterministyczna liczba 0–1 z pary współrzędnych i ziarna.
3
+ *
4
+ * Siatka nie może trzymać stanu per komórka między klatkami — przy zmianie rozmiaru komórki
5
+ * wędrują, a tablica `Math.random()` rozsypałaby cały obraz. Zamiast tego każda komórka wylicza
6
+ * swoje losowania z własnej pozycji: ta sama kolumna i wiersz zawsze dają ten sam wynik, więc
7
+ * po zmianie rozmiaru układ pozostaje sobą, a nie mruga od nowa.
8
+ *
9
+ * Miks jest wariantem lawiny z `Math.imul` — tanio i z porządnym rozrzutem na sąsiednich
10
+ * współrzędnych, gdzie zwykłe mnożenie zostawiałoby widoczne pasy.
11
+ */
12
+ export declare function kptHash01(x: number, y: number, seed?: number): number;
package/dist/hash.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Deterministyczna liczba 0–1 z pary współrzędnych i ziarna.
3
+ *
4
+ * Siatka nie może trzymać stanu per komórka między klatkami — przy zmianie rozmiaru komórki
5
+ * wędrują, a tablica `Math.random()` rozsypałaby cały obraz. Zamiast tego każda komórka wylicza
6
+ * swoje losowania z własnej pozycji: ta sama kolumna i wiersz zawsze dają ten sam wynik, więc
7
+ * po zmianie rozmiaru układ pozostaje sobą, a nie mruga od nowa.
8
+ *
9
+ * Miks jest wariantem lawiny z `Math.imul` — tanio i z porządnym rozrzutem na sąsiednich
10
+ * współrzędnych, gdzie zwykłe mnożenie zostawiałoby widoczne pasy.
11
+ */
12
+ export function kptHash01(x, y, seed = 0) {
13
+ let h = Math.imul(x | 0, 0x1f1f_1f1f) ^ Math.imul(y | 0, 0x27d4_eb2f) ^ Math.imul(seed | 0, 0x1656_67b1);
14
+ h = Math.imul(h ^ (h >>> 15), 0x2545_f491);
15
+ h ^= h >>> 13;
16
+ h = Math.imul(h, 0x27d4_eb2d);
17
+ h ^= h >>> 16;
18
+ return (h >>> 0) / 4_294_967_296;
19
+ }
@@ -0,0 +1,9 @@
1
+ export type { KptBackdropAnchor, KptBackdropEffect, KptSignalGridAlphaOptions, KptSignalGridCell, KptSignalGridFrame, KptSignalGridOptions, KptWaveHarmonic, KptWaveOptions, KptWaveSettings, } from './types.ts';
2
+ export { buildSignalGrid, signalGridAlpha, signalGridDensity, KPT_SIGNAL_GRID_CELL, KPT_SIGNAL_GRID_DENSITY, KPT_SIGNAL_GRID_FALLOFF, KPT_SIGNAL_GRID_FLOOR, KPT_SIGNAL_GRID_GAP, KPT_SIGNAL_GRID_RATE, } from './signal-grid.ts';
3
+ export { resolveWave, waveBuffer, waveIntensity, waveRamp, KPT_WAVE_AMPLITUDE, KPT_WAVE_BAND, KPT_WAVE_DRIFT, KPT_WAVE_MIN_SIDE, KPT_WAVE_RESOLUTION, KPT_WAVE_ROTATION, KPT_WAVE_SOFTNESS, KPT_WAVE_SPREAD, } from './wave.ts';
4
+ export type { KptWaveBuffer } from './wave.ts';
5
+ export { adaptLightness, adaptToSurface, mixOklch, oklabToSrgb, parseHexColor, prefersDarkContent, relativeLuminance, readCssColor, shiftHue, srgbToOklab, toHexColor, } from './color.ts';
6
+ export type { KptOklab, KptRgb } from './color.ts';
7
+ export { createBackdropRenderer, createSignalGridRenderer, adaptBackdropValue, createWaveRenderer, observeBackdropTheme, readBackdropInk, readBackdropLead, readBackdropSurface, resolveBackdropColors, resolveCssValue, KPT_BACKDROP_MAX_DPR, KPT_BACKDROP_MAX_STEP, KPT_BACKDROP_RAMP_SPREAD, } from './renderer.ts';
8
+ export type { KptBackdropColors, KptBackdropInk, KptBackdropRenderer, KptBackdropRenderOptions } from './renderer.ts';
9
+ export { kptHash01 } from './hash.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { buildSignalGrid, signalGridAlpha, signalGridDensity, KPT_SIGNAL_GRID_CELL, KPT_SIGNAL_GRID_DENSITY, KPT_SIGNAL_GRID_FALLOFF, KPT_SIGNAL_GRID_FLOOR, KPT_SIGNAL_GRID_GAP, KPT_SIGNAL_GRID_RATE, } from "./signal-grid.js";
2
+ export { resolveWave, waveBuffer, waveIntensity, waveRamp, KPT_WAVE_AMPLITUDE, KPT_WAVE_BAND, KPT_WAVE_DRIFT, KPT_WAVE_MIN_SIDE, KPT_WAVE_RESOLUTION, KPT_WAVE_ROTATION, KPT_WAVE_SOFTNESS, KPT_WAVE_SPREAD, } from "./wave.js";
3
+ export { adaptLightness, adaptToSurface, mixOklch, oklabToSrgb, parseHexColor, prefersDarkContent, relativeLuminance, readCssColor, shiftHue, srgbToOklab, toHexColor, } from "./color.js";
4
+ export { createBackdropRenderer, createSignalGridRenderer, adaptBackdropValue, createWaveRenderer, observeBackdropTheme, readBackdropInk, readBackdropLead, readBackdropSurface, resolveBackdropColors, resolveCssValue, KPT_BACKDROP_MAX_DPR, KPT_BACKDROP_MAX_STEP, KPT_BACKDROP_RAMP_SPREAD, } from "./renderer.js";
5
+ export { kptHash01 } from "./hash.js";
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Renderery efektów tła.
3
+ *
4
+ * Rysowanie nie potrzebuje frameworka — potrzebuje `CanvasRenderingContext2D`. Dzięki temu
5
+ * komponent w Angularze i w Reakcie zostaje samym cyklem życia (rAF, obserwatory rozmiaru,
6
+ * widoczności i motywu) wokół tego samego renderera, zamiast dwa razy powtarzać pętlę rysowania.
7
+ *
8
+ * Kolory wchodzą tu już rozwiązane: canvas nie rozwija `var()`, więc token na konkretny kolor
9
+ * zamienia wywołujący, który jedyny wie, na jakim elemencie go odczytać.
10
+ */
11
+ import type { KptRgb } from './color.ts';
12
+ import type { KptBackdropAnchor, KptBackdropEffect } from './types.ts';
13
+ /** Gęstość pikseli powyżej dwóch nic nie wnosi przy tych efektach, a kosztuje kwadratowo. */
14
+ export declare const KPT_BACKDROP_MAX_DPR = 2;
15
+ /** Górna granica kroku czasu (ms) — karta wróciwszy z tła nie ma przewijać animacji do przodu. */
16
+ export declare const KPT_BACKDROP_MAX_STEP = 100;
17
+ /** O tyle stopni rozchodzą się domyślne barwy wstęgi względem koloru wiodącego. */
18
+ export declare const KPT_BACKDROP_RAMP_SPREAD = 28;
19
+ /**
20
+ * Renderer jednego efektu. Żyje tyle, co zestaw ustawień — zmiana wejść buduje nowy,
21
+ * zamiast doklejać do istniejącego ścieżki „co się zmieniło".
22
+ */
23
+ export interface KptBackdropRenderer {
24
+ /** Nowy rozmiar obszaru w pikselach CSS. */
25
+ resize(width: number, height: number): void;
26
+ /** Jedna klatka; `time` w sekundach od startu animacji. */
27
+ draw(context: CanvasRenderingContext2D, time: number): void;
28
+ /** Zwalnia bufory. Po tym renderer już nie rysuje. */
29
+ dispose(): void;
30
+ }
31
+ /** Ustawienia renderera — płaskie, tak jak wejścia komponentu. */
32
+ export interface KptBackdropRenderOptions {
33
+ effect?: KptBackdropEffect;
34
+ /** signal-grid: kolor kwadratów, dowolny zapis CSS. */
35
+ color?: string;
36
+ anchor?: KptBackdropAnchor;
37
+ cell?: number;
38
+ gap?: number;
39
+ density?: number;
40
+ falloff?: number;
41
+ /**
42
+ * wave: rampa barw; zapis heksadecymalny, bo barwy trzeba wymieszać, nie tylko podać dalej.
43
+ * Zapis CSS zna wyłącznie `resolveBackdropColors` i to on sprowadza rampę do tej postaci.
44
+ */
45
+ colors?: readonly string[];
46
+ rotation?: number;
47
+ band?: number;
48
+ amplitude?: number;
49
+ seed?: number;
50
+ }
51
+ /** Kolory gotowe do podania rendererowi. */
52
+ export interface KptBackdropColors {
53
+ /** Kolor wiodący — dowolny zapis CSS, tak jak przyszedł. */
54
+ color: string;
55
+ /** Rampa wstęgi w zapisie heksadecymalnym. */
56
+ colors: readonly string[];
57
+ /** Wpisy `colors`, których przeglądarka nie uznała za kolor — do ostrzeżenia w buildzie dev. */
58
+ invalid: readonly string[];
59
+ }
60
+ /**
61
+ * Wyrażenie CSS rozwiązane względem drzewa: `var()` podstawione wartościami obowiązującymi w tym
62
+ * miejscu dokumentu. Dzięki temu wejście koloru może sięgnąć po token i samo iść za motywem,
63
+ * zamiast zamarzać na wartości z chwili napisania szablonu.
64
+ *
65
+ * `scope` musi być **potomkiem** hosta. `observeBackdropTheme` ogląda `style` na hoście i każdym
66
+ * jego przodku, więc sonda zapisana na hoście obudziłaby nasłuch, ten sięgnąłby po kolor na nowo,
67
+ * znów zapisał sondę — i pętla nie miałaby końca. Potomek jest poza zasięgiem nasłuchu.
68
+ *
69
+ * Bramka na `var(` nie jest kosmetyczna: bez niej każdy literał kosztowałby zapis stylu
70
+ * i wymuszone przeliczenie kaskady, a nie ma w nim czego rozwiązywać.
71
+ *
72
+ * Odwołanie do nieistniejącej zmiennej daje pusty napis — to sygnał „nie dało się", który
73
+ * wywołujący czyta jako brak wartości.
74
+ */
75
+ export declare function resolveCssValue(scope: HTMLElement, value: string): string;
76
+ /**
77
+ * Sam kolor wiodący: wejście komponentu, a bez niego token odczytany z wyliczonego stylu.
78
+ *
79
+ * Wydzielone z `resolveBackdropColors`, bo służy też za tani strażnik przy zmianie motywu —
80
+ * `getComputedStyle` bez sondy 1×1 i bez arytmetyki barw.
81
+ *
82
+ * Wejście przechodzi przez `resolveCssValue`, więc `var(--kpt-color-primary)` działa i zmienia się
83
+ * razem z motywem, a literał zostaje literałem. Zapis odwołujący się do zmiennej, której nie ma,
84
+ * schodzi do tokenu — tego chciałby autor literówki w nazwie.
85
+ */
86
+ export declare function readBackdropLead(scope: HTMLElement, color: string): string;
87
+ /** Powierzchnia bieżącego motywu — punkt odniesienia adaptacji. */
88
+ export declare function readBackdropSurface(scope: HTMLElement): KptRgb | null;
89
+ /**
90
+ * Barwa przeniesiona na powierzchnię bieżącego motywu. Wartość, która nie podlega adaptacji albo
91
+ * której nie da się odczytać, wraca bez zmian — adaptacja nigdy nie gubi tego, co podano.
92
+ */
93
+ export declare function adaptBackdropValue(value: string, surface: KptRgb | null): string;
94
+ /** Czym sekcja pisze po swoim tle. */
95
+ export interface KptBackdropInk {
96
+ /**
97
+ * Jasność tła sekcji: `light` znaczy jasne tło i ciemną treść. Puste znaczy „sekcja nie ma
98
+ * własnego tła" i wtedy nie ma czego ogłaszać.
99
+ */
100
+ tone: '' | 'light' | 'dark';
101
+ /** Kolor treści dobrany do tego tła; pusty, gdy nie ma własnego tła. */
102
+ color: string;
103
+ }
104
+ /**
105
+ * Ton i kolor treści dobrane do tła, które sekcja naprawdę maluje.
106
+ *
107
+ * Host ustawia `color` tokenem powierzchni strony, ale sekcja z własnym `background` przestaje być
108
+ * tą powierzchnią — i wtedy token opisuje sąsiada, nie to, na czym treść leży. Bez tego rachunku
109
+ * ciemne tło w motywie jasnym daje czarny napis na czarnym.
110
+ *
111
+ * Ton wychodzi też na zewnątrz atrybutem, bo o kolor treści dopomina się nie tylko sam napis:
112
+ * przyciski i inne komponenty w sekcji potrzebują kompletu tokenów pod to tło, a tego nie da się
113
+ * odziedziczyć po `color`.
114
+ */
115
+ export declare function readBackdropInk(scope: HTMLElement, background: string): KptBackdropInk;
116
+ /**
117
+ * Nasłuch na wszystkim, co może przestawić tokeny pod hostem. Zwraca funkcję odpinającą.
118
+ *
119
+ * Motyw bywa zakresowy — `[data-theme="dark"]` nie jest przywiązany do `:root`, więc tokeny
120
+ * przestawia dowolny przodek. Łańcuch od hosta w górę jest krótki i znany, więc obserwujemy go
121
+ * w całości, zamiast puszczać `subtree` na dokumencie, gdzie trafieniem byłaby każda zmiana klasy
122
+ * w aplikacji. Pętla kończy się na `<html>`, bo rodzicem `<html>` jest dokument, nie element.
123
+ *
124
+ * Do tego zapytanie o preferencję systemu: aplikacja może trzymać ciemne tokeny w media query
125
+ * zamiast w atrybucie i wtedy nie zmienia się nic, co da się zaobserwować w DOM.
126
+ *
127
+ * Łańcuch przodków jest brany w chwili zapisu — przeniesienie hosta w inne miejsce drzewa wymaga
128
+ * ponownego zapisu. Komponent takiego przypadku nie obsługuje.
129
+ *
130
+ * Nasłuch trafia też w zmiany niezwiązane z barwą (klasa nakładki na `<html>`, dowolne nadpisanie
131
+ * inline), więc wywołujący ma porównać `readBackdropLead` z poprzednim odczytem, zanim cokolwiek
132
+ * przebuduje.
133
+ */
134
+ export declare function observeBackdropTheme(host: HTMLElement, onChange: () => void): () => void;
135
+ /**
136
+ * Zamienia wejścia koloru na wartości, które rozumie renderer.
137
+ *
138
+ * Canvas nie rozwija `var()`, więc token trzeba odczytać z wyliczonego stylu elementu — komponent
139
+ * jest jedynym miejscem, które wie, z którego. Pusta rampa bierze kolor wiodący i dwie barwy
140
+ * wyprowadzone z niego obrotem odcienia: paleta biblioteki jest neutralna, więc sięganie po tokeny
141
+ * statusowe (`info`, `success`) byłoby użyciem semantyki do dekoracji.
142
+ *
143
+ * `adaptive` przenosi barwy wpisane na sztywno na powierzchnię bieżącego motywu — czerń wpisana
144
+ * na białe tło staje się bielą, gdy tło jest ciemne. Dotyczy wyłącznie literałów: puste wejście
145
+ * bierze token, a zapis z `var()` sam sięga po token i jedno i drugie już idzie za motywem.
146
+ *
147
+ * Rampa podana wprost przechodzi tą samą drogą co domyślna: każdy wpis najpierw rozwiązujemy
148
+ * względem drzewa (`resolveCssValue`), a potem pytamy przeglądarkę o jego znaczenie. Działa więc
149
+ * i `oklch()`, i `color-mix()`, i nazwa własna, i odwołanie do tokenu — a `color-mix()` z tokenem
150
+ * w środku daje rampę, która sama idzie za motywem. Dalej, do renderera, trafia już sam hex.
151
+ * Wpisy, których przeglądarka nie przyjęła, wracają w `invalid` — rdzeń nie ma własnego trybu dev,
152
+ * więc ostrzega dopiero port. Gdy nie przeszedł żaden, schodzimy do rampy domyślnej: pusty kadr
153
+ * nie powiedziałby nikomu nic.
154
+ *
155
+ * `scope` musi być potomkiem hosta — powód przy `resolveCssValue`.
156
+ *
157
+ * Mieszka w rdzeniu, bo oba porty muszą rozstrzygać to samo tak samo — rozjazd znaczyłby, że ta
158
+ * sama sekcja ma inne barwy w Angularze niż w Reakcie.
159
+ */
160
+ export declare function resolveBackdropColors(scope: HTMLElement, color: string, colors: readonly string[], adaptive?: boolean): KptBackdropColors;
161
+ /**
162
+ * Drobna siatka migoczących kwadratów. Cała matematyka siedzi w `signal-grid.ts`;
163
+ * tutaj zostaje pętla po komórkach.
164
+ */
165
+ export declare function createSignalGridRenderer(options: KptBackdropRenderOptions): KptBackdropRenderer;
166
+ /**
167
+ * Jedna miękka wstęga światła z barwami przesuwającymi się wzdłuż niej.
168
+ *
169
+ * Liczymy ją w buforze o dłuższym boku `KPT_WAVE_RESOLUTION` i skalujemy w górę — miękkość
170
+ * bierze się z interpolacji przeglądarki, więc kilkanaście tysięcy pikseli na klatkę wystarcza
171
+ * na dowolnie duży kadr. Rampa barw idzie przez podręczną tablicę: mieszanie po kole OKLCH raz
172
+ * na piksel byłoby kilkoma pierwiastkami i arkusem tangensa na każdy z nich.
173
+ */
174
+ export declare function createWaveRenderer(options: KptBackdropRenderOptions): KptBackdropRenderer;
175
+ /** Renderer wskazanego efektu. Nieznany efekt nie rysuje niczego, zamiast wywracać klatkę. */
176
+ export declare function createBackdropRenderer(options: KptBackdropRenderOptions): KptBackdropRenderer;