@konce-pt/theme 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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Rozwiązywanie motywu: stock → recipe → overrides → custom.
3
+ *
4
+ * Wartości stockowe są trzymane surowo, z referencjami `{--kpt-nazwa}`, i to jest tu cała sztuczka.
5
+ * Gdyby stock był spłaszczony do liczb, nadpisanie prymitywu `--kpt-radius-md` nie ruszyłoby
6
+ * `--kpt-button-radius`, który się na niego powołuje — a w przeglądarce ruszyłoby, bo tam robi to
7
+ * kaskada przez `var()`. Podgląd rozjeżdżałby się z eksportem dokładnie w tym miejscu, w którym
8
+ * użytkownik akurat kręci suwakiem promienia.
9
+ *
10
+ * Wartości z przepisu wchodzą tą samą drogą co ręczne nadpisania, ale **przed** nimi: ręczna
11
+ * zmiana roli zawsze wygrywa z generatorem i przeliczenie palety od nowa jej nie kasuje.
12
+ */
13
+ import { buildRamp, readSeed } from "./ramp.js";
14
+ import { derivePrimary } from "./roles.js";
15
+ import { KPT_RAW, KPT_SEMANTIC_TOKENS } from "./generated/tokens.js";
16
+ const MODES = ['light', 'dark'];
17
+ const REFERENCE = /\{(--[a-z0-9-]+)\}/g;
18
+ const LENGTH = /^(-?(?:\d*\.\d+|\d+))([a-z%]*)$/i;
19
+ /**
20
+ * Mnoży wartość długości przez współczynnik, zachowując jednostkę. Wartość, która nie jest
21
+ * pojedynczą liczbą z jednostką (np. `clamp()` albo lista), wraca bez zmiany — skalowanie czegoś,
22
+ * czego nie rozumiemy, dawałoby wynik gorszy niż zostawienie tego w spokoju.
23
+ */
24
+ export function scaleLength(value, factor) {
25
+ const match = LENGTH.exec(value.trim());
26
+ if (!match)
27
+ return value;
28
+ const scaled = Number(match[1]) * factor;
29
+ // Trzy miejsca wystarczą dla rem i px, a ucięcie zer trzyma wartości czytelnymi w diffie.
30
+ return `${Number(scaled.toFixed(3))}${match[2]}`;
31
+ }
32
+ const namesStartingWith = (prefix) => Object.keys(KPT_RAW).filter((n) => n.startsWith(prefix));
33
+ /**
34
+ * Tokeny skalowalne z danej rodziny: te, których wartość stockowa jest liczbą z jednostką.
35
+ * `--kpt-radius-full` (9999px) i `--kpt-radius-none` (0) przechodzą przez mnożenie bez szkody —
36
+ * zero zostaje zerem, a „pełny" promień i tak jest tylko umownie dużą liczbą.
37
+ */
38
+ const scaleFamily = (prefix, factor, mode, into) => {
39
+ for (const name of namesStartingWith(prefix)) {
40
+ into[name] = scaleLength(KPT_RAW[name][mode], factor);
41
+ }
42
+ };
43
+ /**
44
+ * Wartości wynikające z przepisu, osobno dla każdego motywu. To jest jedyne miejsce, w którym
45
+ * `recipe` zamienia się w tokeny — dalej wszystko jest już zwykłym nadpisaniem.
46
+ */
47
+ export function recipeLayer(doc, mode) {
48
+ const recipe = doc.recipe;
49
+ if (!recipe)
50
+ return {};
51
+ const out = {};
52
+ const seedValue = recipe.color?.seed?.primary;
53
+ if (seedValue) {
54
+ const seed = readSeed(seedValue);
55
+ if (!seed)
56
+ throw new TypeError(`recipe.color.seed.primary: "${seedValue}" nie jest kolorem`);
57
+ Object.assign(out, derivePrimary(buildRamp(seed), mode));
58
+ }
59
+ const radius = recipe.radius;
60
+ if (radius) {
61
+ // `base` wyrażamy przez współczynnik, żeby cała skala promieni zachowała swoje proporcje:
62
+ // ustawienie samego `md` rozjechałoby `sm` i `lg` względem niego.
63
+ const stockBase = Number(LENGTH.exec(KPT_RAW['--kpt-radius-md'].light)?.[1] ?? 0);
64
+ const fromBase = radius.base ? Number(LENGTH.exec(radius.base)?.[1] ?? NaN) / stockBase : 1;
65
+ const factor = (Number.isFinite(fromBase) ? fromBase : 1) * (radius.scale ?? 1);
66
+ if (factor !== 1)
67
+ scaleFamily('--kpt-radius-', factor, mode, out);
68
+ }
69
+ const typography = recipe.typography;
70
+ if (typography) {
71
+ if (typography.sans)
72
+ out['--kpt-font-family-sans'] = typography.sans;
73
+ if (typography.mono)
74
+ out['--kpt-font-family-mono'] = typography.mono;
75
+ const stockBase = Number(LENGTH.exec(KPT_RAW['--kpt-font-size-md'].light)?.[1] ?? 0);
76
+ const fromBase = typography.baseSize ? Number(LENGTH.exec(typography.baseSize)?.[1] ?? NaN) / stockBase : 1;
77
+ const factor = (Number.isFinite(fromBase) ? fromBase : 1) * (typography.sizeScale ?? 1);
78
+ if (factor !== 1)
79
+ scaleFamily('--kpt-font-size-', factor, mode, out);
80
+ }
81
+ const spacingScale = recipe.spacing?.scale;
82
+ // Porównanie z `null`, nie test prawdziwości: `0` jest poprawną skalą (choć osobliwą),
83
+ // a `if (spacingScale && …)` cicho by ją pominął. Promienie i typografia już tak nie robią.
84
+ if (spacingScale != null && spacingScale !== 1)
85
+ scaleFamily('--kpt-space-', spacingScale, mode, out);
86
+ return out;
87
+ }
88
+ /**
89
+ * Warstwa nadpisań dla jednego motywu: przepis, potem nadpisania ręczne, potem tokeny własne.
90
+ * Zwraca wartości **surowe** — referencje `{--kpt-...}` są tu nadal dozwolone, bo nadpisanie
91
+ * w rodzaju „primary ma być tym, czym info" jest sensowne i ma przeżyć do eksportu.
92
+ */
93
+ export function themeLayer(doc, mode) {
94
+ const layer = { ...recipeLayer(doc, mode) };
95
+ Object.assign(layer, doc.overrides?.primitive ?? {});
96
+ Object.assign(layer, doc.overrides?.component ?? {});
97
+ Object.assign(layer, doc.overrides?.semantic?.[mode] ?? {});
98
+ for (const [name, token] of Object.entries(doc.custom ?? {})) {
99
+ layer[name] = (mode === 'dark' ? token.dark : token.light) ?? token.light;
100
+ }
101
+ return layer;
102
+ }
103
+ const resolveMap = (raw) => {
104
+ const done = {};
105
+ const resolveOne = (name, seen) => {
106
+ if (done[name] !== undefined)
107
+ return done[name];
108
+ if (seen.includes(name))
109
+ throw new Error(`Cykl referencji tokenów: ${[...seen, name].join(' → ')}`);
110
+ const value = raw[name];
111
+ // Referencja do tokenu, którego nie ma, zostaje w wyniku dosłownie — jest wtedy widoczna
112
+ // w podglądzie i w eksporcie, zamiast zamienić się w pustkę, której nikt nie umie wyjaśnić.
113
+ if (value === undefined)
114
+ return `{${name}}`;
115
+ const resolved = value.replace(REFERENCE, (_, ref) => resolveOne(ref, [...seen, name]));
116
+ done[name] = resolved;
117
+ return resolved;
118
+ };
119
+ for (const name of Object.keys(raw))
120
+ resolveOne(name, []);
121
+ return done;
122
+ };
123
+ /** Motyw rozwiązany do wartości dosłownych — to, co widać w podglądzie i mierzy audyt. */
124
+ export function resolveTheme(doc) {
125
+ const out = {};
126
+ for (const mode of MODES) {
127
+ const raw = {};
128
+ for (const [name, value] of Object.entries(KPT_RAW))
129
+ raw[name] = value[mode];
130
+ if (doc)
131
+ Object.assign(raw, themeLayer(doc, mode));
132
+ out[mode] = resolveMap(raw);
133
+ }
134
+ return out;
135
+ }
136
+ /** Stock rozwiązany — punkt odniesienia dla różnicy. Liczony raz, bo się nie zmienia. */
137
+ let stockCache;
138
+ export const stockTheme = () => (stockCache ??= resolveTheme());
139
+ /**
140
+ * Tokeny, których wartość różni się od stockowej — czyli to, co realnie trzeba wyemitować.
141
+ * Liczone po **rozwiązaniu**, więc nadpisanie prymitywu pokazuje się tu jako komplet ról, które
142
+ * za nim poszły, a nie jako jeden wpis, którego skutków nie widać.
143
+ */
144
+ export function themeDiff(doc, mode) {
145
+ const resolved = resolveTheme(doc)[mode];
146
+ const stock = stockTheme()[mode];
147
+ const diff = {};
148
+ for (const [name, value] of Object.entries(resolved)) {
149
+ if (stock[name] !== value)
150
+ diff[name] = value;
151
+ }
152
+ return diff;
153
+ }
154
+ const SEMANTIC = new Set(KPT_SEMANTIC_TOKENS);
155
+ export const isSemanticToken = (name) => SEMANTIC.has(name);
156
+ export const isKnownToken = (name) => KPT_RAW[name] !== undefined;
157
+ /**
158
+ * Prymityw poznajemy po prefiksie nazwy — to jedyna informacja o warstwie, jaką niesie sama nazwa.
159
+ * Trzyma to `resolve.ts`, a nie emiter, bo pytają o to trzy różne rzeczy: eksport (co wypisać),
160
+ * import (gdzie to włożyć) i edytor (który panel).
161
+ */
162
+ const PRIMITIVE_PREFIXES = [
163
+ '--kpt-color-neutral-',
164
+ '--kpt-color-red-',
165
+ '--kpt-color-green-',
166
+ '--kpt-color-amber-',
167
+ '--kpt-color-blue-',
168
+ '--kpt-space-',
169
+ '--kpt-radius-',
170
+ '--kpt-border-width-',
171
+ '--kpt-z-',
172
+ '--kpt-motion-',
173
+ '--kpt-font-',
174
+ '--kpt-breakpoint-',
175
+ ];
176
+ export const isPrimitiveToken = (name) => PRIMITIVE_PREFIXES.some((p) => name.startsWith(p));
177
+ /** Wszystkie nazwy tokenów biblioteki — importer dopasowuje do nich dokładnie, bez synonimów. */
178
+ export const KPT_ALL_TOKENS = Object.keys(KPT_RAW);
179
+ /**
180
+ * Nazwy z dokumentu, których biblioteka nie zna. Motyw zapisany pod starszą wersją ma prawo się
181
+ * otworzyć i dopiero wtedy powiedzieć, co zniknęło — dlatego to jest osobne pytanie, a nie błąd
182
+ * parsowania.
183
+ */
184
+ export function unknownTokens(doc) {
185
+ const names = new Set([
186
+ ...Object.keys(doc.overrides?.primitive ?? {}),
187
+ ...Object.keys(doc.overrides?.component ?? {}),
188
+ ...Object.keys(doc.overrides?.semantic?.light ?? {}),
189
+ ...Object.keys(doc.overrides?.semantic?.dark ?? {}),
190
+ ]);
191
+ return [...names].filter((name) => !isKnownToken(name)).sort();
192
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Derywacja ról semantycznych z rampy.
3
+ *
4
+ * Który stopień rampy trafia na którą rolę, nie jest tu wymyślone: biblioteka ma cztery rodziny
5
+ * chromatyczne (danger/success/warning/info) o identycznym układzie stopni i to on jest wzorcem.
6
+ * Generator odczytuje go z tokenów i pilnuje, żeby te cztery rodziny się nie rozjechały — więc
7
+ * kolor wiodący wyprowadzony z ziarna zachowuje się dokładnie tak, jak zachowują się kolory
8
+ * statusowe, które biblioteka już wydaje.
9
+ *
10
+ * Rola `-active` jest jedynym wyjątkiem, bo rodziny statusowe jej nie mają: wzorzec jest tam
11
+ * przedłużony o jeden krok w tę samą stronę, w którą idzie `-hover`.
12
+ */
13
+ import type { KptRamp } from './ramp.ts';
14
+ import type { KptThemeMode } from './schema.ts';
15
+ /**
16
+ * Treść czytelniejsza na danym wypełnieniu, wybrana przez APCA — nie przez próg luminancji.
17
+ * Próg jest jedną liczbą dla wszystkich barw i myli się na nasyconych średnich jasnościach,
18
+ * czyli dokładnie tam, gdzie lądują kolory marki.
19
+ */
20
+ export declare function contentOn(fill: string, mode: KptThemeMode): string;
21
+ /**
22
+ * Rodzina koloru wiodącego dla jednego motywu: sześć ról z rampy plus `--kpt-color-on-primary`
23
+ * policzone kontrastem. `--kpt-color-primary-contrast` i `--kpt-color-text-inverse` nie są tu
24
+ * wymienione, bo w stocku są referencjami do `on-primary` — pójdą za nim same, przez graf.
25
+ */
26
+ export declare function derivePrimary(ramp: KptRamp, mode: KptThemeMode): Record<string, string>;
27
+ /** Nazwy ról, które przestawia ziarno koloru wiodącego. Edytor oznacza je jako „z przepisu". */
28
+ export declare const KPT_PRIMARY_ROLES: readonly string[];
package/dist/roles.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Derywacja ról semantycznych z rampy.
3
+ *
4
+ * Który stopień rampy trafia na którą rolę, nie jest tu wymyślone: biblioteka ma cztery rodziny
5
+ * chromatyczne (danger/success/warning/info) o identycznym układzie stopni i to on jest wzorcem.
6
+ * Generator odczytuje go z tokenów i pilnuje, żeby te cztery rodziny się nie rozjechały — więc
7
+ * kolor wiodący wyprowadzony z ziarna zachowuje się dokładnie tak, jak zachowują się kolory
8
+ * statusowe, które biblioteka już wydaje.
9
+ *
10
+ * Rola `-active` jest jedynym wyjątkiem, bo rodziny statusowe jej nie mają: wzorzec jest tam
11
+ * przedłużony o jeden krok w tę samą stronę, w którą idzie `-hover`.
12
+ */
13
+ import { apcaLc, formatOklch, oklchToSrgb, parseOklch } from '@konce-pt/color';
14
+ import { KPT_ACCENT_PATTERN, KPT_RAW } from "./generated/tokens.js";
15
+ /** Rodzina koloru wiodącego: rola → slot wzorca akcentu. */
16
+ const PRIMARY_FAMILY = {
17
+ '--kpt-color-primary': 'base',
18
+ '--kpt-color-primary-hover': 'hover',
19
+ '--kpt-color-primary-active': 'active',
20
+ '--kpt-color-primary-subtle': 'subtle',
21
+ '--kpt-color-primary-border': 'border',
22
+ // Pierścień focus chodzi za kolorem wiodącym — w stocku jest dokładnie tym samym tokenem.
23
+ '--kpt-color-focus-ring': 'base',
24
+ };
25
+ /**
26
+ * Kandydaci na treść leżącą na wypełnieniu akcentowym. Bierzemy je ze stocku, a nie z czerni
27
+ * i bieli wprost: `--kpt-color-neutral-0` i `-950` to te same wartości, których biblioteka używa
28
+ * w rolach `*-contrast`, więc wybór zostaje w palecie zamiast wprowadzać barwy spoza niej.
29
+ */
30
+ const CONTENT_CANDIDATES = ['--kpt-color-neutral-0', '--kpt-color-neutral-950'];
31
+ const toRgb = (value) => {
32
+ const oklch = parseOklch(value);
33
+ return oklch ? oklchToSrgb(oklch) : null;
34
+ };
35
+ /**
36
+ * Treść czytelniejsza na danym wypełnieniu, wybrana przez APCA — nie przez próg luminancji.
37
+ * Próg jest jedną liczbą dla wszystkich barw i myli się na nasyconych średnich jasnościach,
38
+ * czyli dokładnie tam, gdzie lądują kolory marki.
39
+ */
40
+ export function contentOn(fill, mode) {
41
+ const background = toRgb(fill);
42
+ if (!background)
43
+ return KPT_RAW[CONTENT_CANDIDATES[0]][mode];
44
+ let best = CONTENT_CANDIDATES[0];
45
+ let score = -Infinity;
46
+ for (const candidate of CONTENT_CANDIDATES) {
47
+ const value = KPT_RAW[candidate][mode];
48
+ const rgb = toRgb(value);
49
+ if (!rgb)
50
+ continue;
51
+ const lc = Math.abs(apcaLc(rgb, background));
52
+ if (lc > score) {
53
+ score = lc;
54
+ best = candidate;
55
+ }
56
+ }
57
+ return KPT_RAW[best][mode];
58
+ }
59
+ /**
60
+ * Rodzina koloru wiodącego dla jednego motywu: sześć ról z rampy plus `--kpt-color-on-primary`
61
+ * policzone kontrastem. `--kpt-color-primary-contrast` i `--kpt-color-text-inverse` nie są tu
62
+ * wymienione, bo w stocku są referencjami do `on-primary` — pójdą za nim same, przez graf.
63
+ */
64
+ export function derivePrimary(ramp, mode) {
65
+ const out = {};
66
+ for (const [token, slot] of Object.entries(PRIMARY_FAMILY)) {
67
+ const step = KPT_ACCENT_PATTERN[slot][mode];
68
+ const color = ramp[step];
69
+ if (!color)
70
+ throw new Error(`Rampa nie ma stopnia ${step} wymaganego przez rolę ${token}`);
71
+ out[token] = formatOklch(color);
72
+ }
73
+ out['--kpt-color-on-primary'] = contentOn(out['--kpt-color-primary'], mode);
74
+ return out;
75
+ }
76
+ /** Nazwy ról, które przestawia ziarno koloru wiodącego. Edytor oznacza je jako „z przepisu". */
77
+ export const KPT_PRIMARY_ROLES = [...Object.keys(PRIMARY_FAMILY), '--kpt-color-on-primary'];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Nakładanie motywu w przeglądarce.
3
+ *
4
+ * To jest ten sam kod, którym podgląd edytora wstrzykuje style — i to nie jest zbieg okoliczności,
5
+ * tylko warunek: podgląd ma pokazywać dokładnie ten tekst CSS, który wychodzi z eksportu.
6
+ *
7
+ * Wstrzykujemy **blok `<style>`**, nie style inline na `<html>`. Inline wystarcza do kilku
8
+ * zmiennych i tak robi to dziś playground, ale nie umie wyrazić różnych wartości dla motywu
9
+ * jasnego i ciemnego w jednym dokumencie — a przede wszystkim nie jest tym, co konsument wklei
10
+ * u siebie. Blok `<style>` jest dosłownie artefaktem eksportu.
11
+ */
12
+ import type { KptEmitOptions } from './emit.ts';
13
+ import type { KptThemeDoc } from './schema.ts';
14
+ /** Identyfikator bloku stylu. Stały, żeby kolejne nałożenia podmieniały, a nie dokładały. */
15
+ export declare const KPT_THEME_STYLE_ID = "kpt-theme";
16
+ export interface KptApplyOptions extends KptEmitOptions {
17
+ /** Dokument docelowy — podgląd w iframe podaje tu `frame.contentDocument`. */
18
+ document?: Document;
19
+ /** Identyfikator bloku stylu, gdy w jednym dokumencie ma żyć więcej niż jeden motyw. */
20
+ id?: string;
21
+ }
22
+ /**
23
+ * Nakłada motyw i zwraca funkcję, która go zdejmuje.
24
+ *
25
+ * Aktualizacja idzie przez podmianę `textContent` jednego węzła — dla dwustu zmiennych to jedno
26
+ * przeliczenie stylów, rząd milisekundy. Przy ciągnięciu suwaka warto to zdławić przez
27
+ * `requestAnimationFrame` po stronie wywołującego; tutaj nie dławimy, bo funkcja ma być
28
+ * przewidywalna, a nie sprytna.
29
+ */
30
+ export declare function applyKptTheme(theme: KptThemeDoc, options?: KptApplyOptions): () => void;
31
+ /** Zdejmuje motyw nałożony przez `applyKptTheme`. Brak bloku nie jest błędem. */
32
+ export declare function clearKptTheme(options?: Pick<KptApplyOptions, 'document' | 'id'>): void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Nakładanie motywu w przeglądarce.
3
+ *
4
+ * To jest ten sam kod, którym podgląd edytora wstrzykuje style — i to nie jest zbieg okoliczności,
5
+ * tylko warunek: podgląd ma pokazywać dokładnie ten tekst CSS, który wychodzi z eksportu.
6
+ *
7
+ * Wstrzykujemy **blok `<style>`**, nie style inline na `<html>`. Inline wystarcza do kilku
8
+ * zmiennych i tak robi to dziś playground, ale nie umie wyrazić różnych wartości dla motywu
9
+ * jasnego i ciemnego w jednym dokumencie — a przede wszystkim nie jest tym, co konsument wklei
10
+ * u siebie. Blok `<style>` jest dosłownie artefaktem eksportu.
11
+ */
12
+ import { emitCss } from "./emit.js";
13
+ /** Identyfikator bloku stylu. Stały, żeby kolejne nałożenia podmieniały, a nie dokładały. */
14
+ export const KPT_THEME_STYLE_ID = 'kpt-theme';
15
+ /**
16
+ * Nakłada motyw i zwraca funkcję, która go zdejmuje.
17
+ *
18
+ * Aktualizacja idzie przez podmianę `textContent` jednego węzła — dla dwustu zmiennych to jedno
19
+ * przeliczenie stylów, rząd milisekundy. Przy ciągnięciu suwaka warto to zdławić przez
20
+ * `requestAnimationFrame` po stronie wywołującego; tutaj nie dławimy, bo funkcja ma być
21
+ * przewidywalna, a nie sprytna.
22
+ */
23
+ export function applyKptTheme(theme, options = {}) {
24
+ const target = options.document ?? (typeof document !== 'undefined' ? document : undefined);
25
+ if (!target)
26
+ throw new Error('applyKptTheme: brak dokumentu — w Node podaj `document` albo użyj emitCss');
27
+ const id = options.id ?? KPT_THEME_STYLE_ID;
28
+ let style = target.getElementById(id);
29
+ if (!style) {
30
+ style = target.createElement('style');
31
+ style.id = id;
32
+ target.head.append(style);
33
+ }
34
+ style.textContent = emitCss(theme, options);
35
+ return () => style?.remove();
36
+ }
37
+ /** Zdejmuje motyw nałożony przez `applyKptTheme`. Brak bloku nie jest błędem. */
38
+ export function clearKptTheme(options = {}) {
39
+ const target = options.document ?? (typeof document !== 'undefined' ? document : undefined);
40
+ target?.getElementById(options.id ?? KPT_THEME_STYLE_ID)?.remove();
41
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Kontrakt dokumentu motywu. To jest format, który edytor zapisuje, eksport czyta, a kiedyś
3
+ * odczyta go chmura — więc ma być mały, płaski i czytelny w code review.
4
+ *
5
+ * Dwie decyzje niosą resztę:
6
+ *
7
+ * 1. **Zapisujemy różnicę, nie zrzut.** Token niezmieniony nie występuje w dokumencie. Motyw waży
8
+ * kilka kilobajtów zamiast kilkudziesięciu, a nowe tokeny w kolejnej wersji biblioteki
9
+ * dziedziczą się same, zamiast zamarzać na wartościach sprzed aktualizacji.
10
+ * 2. **`recipe` (wejścia generatora) jest oddzielone od `overrides` (ręczne odklejenia).** Sam
11
+ * zrzut wartości uniemożliwiłby ponowne otwarcie suwaków; same parametry generatora zgubiłyby
12
+ * ręczne poprawki. Kolejność rozwiązywania to stock → recipe → overrides, a wartości
13
+ * wyprodukowane przez przepis **nie trafiają** do `overrides` — dzięki temu „przelicz paletę
14
+ * od nowa" nie kasuje ręcznej pracy.
15
+ *
16
+ * Kluczem jest nazwa custom property (`--kpt-color-primary`), nie ścieżka Style Dictionary:
17
+ * to jest realny kontrakt publiczny biblioteki i dokładnie to, co konsument wpisuje u siebie.
18
+ */
19
+ export type KptThemeMode = 'light' | 'dark';
20
+ /** Bieżąca wersja formatu. Liczba całkowita — patrz `migrateTheme`. */
21
+ export declare const KPT_THEME_VERSION = 1;
22
+ export interface KptThemeMeta {
23
+ id?: string;
24
+ name?: string;
25
+ author?: string;
26
+ createdAt?: string;
27
+ updatedAt?: string;
28
+ /** Monotoniczny licznik zapisów — podstawa rozstrzygania konfliktów w przyszłej chmurze. */
29
+ revision?: number;
30
+ basedOn?: {
31
+ package: string;
32
+ version: string;
33
+ };
34
+ /**
35
+ * Utrwalone mapowanie ze ścieżki DTCG na nazwę tokenu, zapisane przy imporcie.
36
+ *
37
+ * To ono zamienia jednorazowy import w element procesu pracy: po zmianie brandbooka kolejne
38
+ * wczytanie tego samego pliku nie wymaga ponownego przechodzenia przez ekran mapowania.
39
+ * Jednorazowy importer jest zabawką — powtarzalny jest narzędziem.
40
+ */
41
+ importMap?: Record<string, string>;
42
+ }
43
+ /**
44
+ * Wejścia generatora. Pola nieustawione (albo `null`) znaczą „zostaw stock" — to nie to samo co
45
+ * brak pola, ale jedno i drugie daje ten sam wynik, więc zapis pozostaje odporny na `null`
46
+ * przychodzący z formularza.
47
+ */
48
+ export interface KptThemeRecipe {
49
+ color?: {
50
+ /** Kolor wiodący jako `#rrggbb` albo zapis `oklch()`. Z niego liczona jest cała rodzina. */
51
+ seed?: {
52
+ primary?: string | null;
53
+ } | null;
54
+ } | null;
55
+ radius?: {
56
+ base?: string | null;
57
+ scale?: number | null;
58
+ } | null;
59
+ typography?: {
60
+ sans?: string | null;
61
+ mono?: string | null;
62
+ baseSize?: string | null;
63
+ sizeScale?: number | null;
64
+ } | null;
65
+ spacing?: {
66
+ scale?: number | null;
67
+ } | null;
68
+ }
69
+ export interface KptThemeOverrides {
70
+ /** Nadpisania prymitywów — działają w obu motywach, bo prymitywy są zdefiniowane raz. */
71
+ primitive?: Record<string, string>;
72
+ semantic?: {
73
+ light?: Record<string, string>;
74
+ dark?: Record<string, string>;
75
+ };
76
+ component?: Record<string, string>;
77
+ }
78
+ export interface KptCustomToken {
79
+ light: string;
80
+ /** Brak wartości ciemnej znaczy „ta sama w obu motywach". */
81
+ dark?: string;
82
+ description?: string;
83
+ }
84
+ export interface KptThemeExport {
85
+ /** Selektor motywu ciemnego. Biblioteka używa `[data-theme="dark"]`. */
86
+ darkSelector?: string;
87
+ /** Dołóż blok `@media (prefers-color-scheme: dark)` — biblioteka sama go nie ma. */
88
+ prefersColorScheme?: boolean;
89
+ /**
90
+ * `resolved` emituje całą różnicę wartościami dosłownymi, w obu blokach — działa na każdej wersji
91
+ * biblioteki. `layered` zostawia nadpisania na warstwie prymitywów i pomija to, co z nich wynika —
92
+ * krócej, ale wymaga wersji z poprawką ciemnego arkusza. Domyślnie `resolved`; patrz `emitPlan`.
93
+ */
94
+ compat?: 'resolved' | 'layered';
95
+ }
96
+ export interface KptThemeDoc {
97
+ $schema?: string;
98
+ kptTheme: typeof KPT_THEME_VERSION;
99
+ meta?: KptThemeMeta;
100
+ recipe?: KptThemeRecipe;
101
+ overrides?: KptThemeOverrides;
102
+ custom?: Record<string, KptCustomToken>;
103
+ export?: KptThemeExport;
104
+ }
105
+ /** Pusty motyw — czyli stock. Przydaje się jako punkt startu i jako wartość domyślna. */
106
+ export declare const emptyTheme: () => KptThemeDoc;
107
+ /**
108
+ * Czyta dokument z nieznanego wejścia. Strażniki pisane ręcznie, bez walidatora schematów —
109
+ * tak jak w reszcie repozytorium, i z tego samego powodu: zależność runtime na jednym `parse`
110
+ * kosztuje więcej, niż jest tu warta.
111
+ *
112
+ * Rzuca z komunikatem wskazującym pole. Nie sprawdza, czy nazwy tokenów **istnieją** — od tego
113
+ * jest `unknownTokens`, bo motyw zapisany pod starszą wersją biblioteki ma prawo się otworzyć
114
+ * i dopiero wtedy powiedzieć, co się rozjechało.
115
+ */
116
+ export declare function parseTheme(input: unknown): KptThemeDoc;
117
+ /**
118
+ * Podnosi dokument do bieżącej wersji formatu. Dziś łańcuch jest pusty, bo wersja jest pierwsza —
119
+ * ale wejście jest tu po to, żeby przy wersji 2 nie trzeba było szukać, gdzie wstawić migrację.
120
+ * Czytanie starych plików nie ma prawa się zepsuć: bez tego „zapis projektów" nie jest funkcją,
121
+ * tylko obietnicą.
122
+ */
123
+ export declare function migrateTheme(input: unknown): KptThemeDoc;
124
+ /**
125
+ * Zapis o stabilnej kolejności kluczy. Dokument trafia do repozytorium konsumenta, więc dwa
126
+ * zapisy tego samego motywu muszą dawać ten sam plik — inaczej każdy zapis to szum w diffie.
127
+ */
128
+ export declare function serializeTheme(doc: KptThemeDoc): string;
package/dist/schema.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Kontrakt dokumentu motywu. To jest format, który edytor zapisuje, eksport czyta, a kiedyś
3
+ * odczyta go chmura — więc ma być mały, płaski i czytelny w code review.
4
+ *
5
+ * Dwie decyzje niosą resztę:
6
+ *
7
+ * 1. **Zapisujemy różnicę, nie zrzut.** Token niezmieniony nie występuje w dokumencie. Motyw waży
8
+ * kilka kilobajtów zamiast kilkudziesięciu, a nowe tokeny w kolejnej wersji biblioteki
9
+ * dziedziczą się same, zamiast zamarzać na wartościach sprzed aktualizacji.
10
+ * 2. **`recipe` (wejścia generatora) jest oddzielone od `overrides` (ręczne odklejenia).** Sam
11
+ * zrzut wartości uniemożliwiłby ponowne otwarcie suwaków; same parametry generatora zgubiłyby
12
+ * ręczne poprawki. Kolejność rozwiązywania to stock → recipe → overrides, a wartości
13
+ * wyprodukowane przez przepis **nie trafiają** do `overrides` — dzięki temu „przelicz paletę
14
+ * od nowa" nie kasuje ręcznej pracy.
15
+ *
16
+ * Kluczem jest nazwa custom property (`--kpt-color-primary`), nie ścieżka Style Dictionary:
17
+ * to jest realny kontrakt publiczny biblioteki i dokładnie to, co konsument wpisuje u siebie.
18
+ */
19
+ /** Bieżąca wersja formatu. Liczba całkowita — patrz `migrateTheme`. */
20
+ export const KPT_THEME_VERSION = 1;
21
+ /** Pusty motyw — czyli stock. Przydaje się jako punkt startu i jako wartość domyślna. */
22
+ export const emptyTheme = () => ({ kptTheme: KPT_THEME_VERSION });
23
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ const TOKEN_NAME = /^--[a-z][a-z0-9-]*$/;
25
+ /** Mapa nazwa-tokenu → wartość. Nazwy sprawdzamy, wartości nie — CSS przyjmuje zbyt wiele. */
26
+ const readTokenMap = (value, where) => {
27
+ if (!isRecord(value))
28
+ throw new TypeError(`${where}: oczekiwano obiektu, jest ${typeof value}`);
29
+ const out = {};
30
+ for (const [name, raw] of Object.entries(value)) {
31
+ if (!TOKEN_NAME.test(name))
32
+ throw new TypeError(`${where}: "${name}" nie jest nazwą custom property`);
33
+ if (typeof raw !== 'string')
34
+ throw new TypeError(`${where}.${name}: wartość musi być tekstem`);
35
+ out[name] = raw;
36
+ }
37
+ return out;
38
+ };
39
+ /**
40
+ * Czyta dokument z nieznanego wejścia. Strażniki pisane ręcznie, bez walidatora schematów —
41
+ * tak jak w reszcie repozytorium, i z tego samego powodu: zależność runtime na jednym `parse`
42
+ * kosztuje więcej, niż jest tu warta.
43
+ *
44
+ * Rzuca z komunikatem wskazującym pole. Nie sprawdza, czy nazwy tokenów **istnieją** — od tego
45
+ * jest `unknownTokens`, bo motyw zapisany pod starszą wersją biblioteki ma prawo się otworzyć
46
+ * i dopiero wtedy powiedzieć, co się rozjechało.
47
+ */
48
+ export function parseTheme(input) {
49
+ if (!isRecord(input))
50
+ throw new TypeError('Motyw: oczekiwano obiektu JSON');
51
+ if (input['kptTheme'] !== KPT_THEME_VERSION) {
52
+ throw new TypeError(`Motyw: nieobsługiwana wersja formatu ${String(input['kptTheme'])} (obsługiwana: ${KPT_THEME_VERSION})`);
53
+ }
54
+ const doc = { kptTheme: KPT_THEME_VERSION };
55
+ if (typeof input['$schema'] === 'string')
56
+ doc.$schema = input['$schema'];
57
+ if (isRecord(input['meta']))
58
+ doc.meta = input['meta'];
59
+ if (isRecord(input['recipe']))
60
+ doc.recipe = input['recipe'];
61
+ if (isRecord(input['export']))
62
+ doc.export = input['export'];
63
+ if (input['overrides'] !== undefined) {
64
+ const raw = input['overrides'];
65
+ if (!isRecord(raw))
66
+ throw new TypeError('overrides: oczekiwano obiektu');
67
+ const overrides = {};
68
+ if (raw['primitive'] !== undefined)
69
+ overrides.primitive = readTokenMap(raw['primitive'], 'overrides.primitive');
70
+ if (raw['component'] !== undefined)
71
+ overrides.component = readTokenMap(raw['component'], 'overrides.component');
72
+ if (raw['semantic'] !== undefined) {
73
+ const semantic = raw['semantic'];
74
+ if (!isRecord(semantic))
75
+ throw new TypeError('overrides.semantic: oczekiwano obiektu');
76
+ overrides.semantic = {};
77
+ for (const mode of ['light', 'dark']) {
78
+ if (semantic[mode] !== undefined) {
79
+ overrides.semantic[mode] = readTokenMap(semantic[mode], `overrides.semantic.${mode}`);
80
+ }
81
+ }
82
+ }
83
+ doc.overrides = overrides;
84
+ }
85
+ if (input['custom'] !== undefined) {
86
+ const raw = input['custom'];
87
+ if (!isRecord(raw))
88
+ throw new TypeError('custom: oczekiwano obiektu');
89
+ const custom = {};
90
+ for (const [name, value] of Object.entries(raw)) {
91
+ if (!TOKEN_NAME.test(name))
92
+ throw new TypeError(`custom: "${name}" nie jest nazwą custom property`);
93
+ if (!isRecord(value) || typeof value['light'] !== 'string') {
94
+ throw new TypeError(`custom.${name}: wymagane pole "light" typu tekstowego`);
95
+ }
96
+ const token = { light: value['light'] };
97
+ if (typeof value['dark'] === 'string')
98
+ token.dark = value['dark'];
99
+ if (typeof value['description'] === 'string')
100
+ token.description = value['description'];
101
+ custom[name] = token;
102
+ }
103
+ doc.custom = custom;
104
+ }
105
+ return doc;
106
+ }
107
+ /**
108
+ * Podnosi dokument do bieżącej wersji formatu. Dziś łańcuch jest pusty, bo wersja jest pierwsza —
109
+ * ale wejście jest tu po to, żeby przy wersji 2 nie trzeba było szukać, gdzie wstawić migrację.
110
+ * Czytanie starych plików nie ma prawa się zepsuć: bez tego „zapis projektów" nie jest funkcją,
111
+ * tylko obietnicą.
112
+ */
113
+ export function migrateTheme(input) {
114
+ return parseTheme(input);
115
+ }
116
+ const KEY_ORDER = ['$schema', 'kptTheme', 'meta', 'recipe', 'overrides', 'custom', 'export'];
117
+ /**
118
+ * Zapis o stabilnej kolejności kluczy. Dokument trafia do repozytorium konsumenta, więc dwa
119
+ * zapisy tego samego motywu muszą dawać ten sam plik — inaczej każdy zapis to szum w diffie.
120
+ */
121
+ export function serializeTheme(doc) {
122
+ const ordered = {};
123
+ for (const key of KEY_ORDER) {
124
+ const value = doc[key];
125
+ if (value !== undefined)
126
+ ordered[key] = value;
127
+ }
128
+ return `${JSON.stringify(ordered, null, 2)}\n`;
129
+ }