@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/audit.d.ts +59 -0
- package/dist/audit.js +102 -0
- package/dist/dtcg.d.ts +80 -0
- package/dist/dtcg.js +624 -0
- package/dist/emit.d.ts +76 -0
- package/dist/emit.js +276 -0
- package/dist/generated/tokens.d.ts +24 -0
- package/dist/generated/tokens.js +348 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/ramp.d.ts +41 -0
- package/dist/ramp.js +66 -0
- package/dist/resolve.d.ts +56 -0
- package/dist/resolve.js +192 -0
- package/dist/roles.d.ts +28 -0
- package/dist/roles.js +77 -0
- package/dist/runtime.d.ts +32 -0
- package/dist/runtime.js +41 -0
- package/dist/schema.d.ts +128 -0
- package/dist/schema.js +129 -0
- package/package.json +52 -0
package/dist/resolve.js
ADDED
|
@@ -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
|
+
}
|
package/dist/roles.d.ts
ADDED
|
@@ -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;
|
package/dist/runtime.js
ADDED
|
@@ -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
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -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
|
+
}
|