@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 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,65 @@
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/theme
6
+
7
+ Rdzeń motywu [Koncept UI](https://gitlab.com/konce-pt/koncept-ui): format dokumentu
8
+ `kpt-theme.json`, rampa OKLCH z jednego koloru marki, derywacja kompletu ról semantycznych, audyt
9
+ kontrastu i emitery do CSS, SCSS, TS oraz W3C DTCG. Czysty TypeScript bez wiązania z frameworkiem;
10
+ jedyna zależność to [`@konce-pt/color`](https://www.npmjs.com/package/@konce-pt/color).
11
+
12
+ Motyw dla tej biblioteki to zestaw wartości `--kpt-*` — a policzenie tego zestawu jest arytmetyką,
13
+ nie znacznikami. Ta sama arytmetyka musi chodzić w przeglądarce (podgląd na żywo) i w Node (eksport,
14
+ testy), więc mieszka osobno od aplikacji, która ją renderuje.
15
+
16
+ ```bash
17
+ npm i @konce-pt/theme
18
+ ```
19
+
20
+ ```ts
21
+ import { emitCss, auditTheme } from '@konce-pt/theme';
22
+
23
+ const theme = {
24
+ kptTheme: 1,
25
+ meta: { name: 'Acme' },
26
+ recipe: { color: { seed: { primary: '#3b82f6' } }, radius: { base: '0.75rem' } },
27
+ } as const;
28
+
29
+ emitCss(theme); // :root { … } + [data-theme="dark"] { … } — plik do wklejenia w aplikacji
30
+ auditTheme(theme); // pary o zbyt niskim kontraście, w obu motywach, WCAG 2.1 + APCA
31
+ ```
32
+
33
+ ## Różnica, nie zrzut
34
+
35
+ Dokument zapisuje wyłącznie to, co odbiega od stocku. Motyw waży kilka kilobajtów zamiast
36
+ kilkudziesięciu, czyta się go w code review, a nowe tokeny w kolejnej wersji biblioteki dziedziczą
37
+ się same, zamiast zamarzać na wartościach sprzed aktualizacji. Osobno trzymane są wejścia generatora
38
+ (`recipe`) i ręczne poprawki (`overrides`): dzięki temu „przelicz paletę od nowa" nie kasuje ręcznej
39
+ pracy, a suwaki dają się ponownie otworzyć.
40
+
41
+ ## Rampa czytana z palety, nie wymyślona
42
+
43
+ Jasność każdego stopnia pochodzi z rampy neutralnej biblioteki, kształt nasycenia — z uśrednienia
44
+ czterech ramp akcentowych. Który stopień trafia na `primary`, `-hover`, `-subtle` i `-border`, też
45
+ jest odczytane: cztery rodziny chromatyczne biblioteki mają identyczny układ i to on jest wzorcem.
46
+ Generator pęka głośno, gdyby kiedykolwiek się rozjechały.
47
+
48
+ ## Audyt, który nie krzyczy bez powodu
49
+
50
+ Każdy wynik niesie `inherited` — czy ta sama para nie zdaje już w stockowym motywie biblioteki.
51
+ Użytkownik widzi to, co **on** popsuł, zamiast dziedziczyć listę, na którą nie ma wpływu. Ukrywać
52
+ ich zupełnie jednak nie wolno: część z nich to prawdziwe problemy.
53
+
54
+ Pełny opis: [`llms.txt`](https://ui.konce.pt/llms/theme/llms.txt) (EN)
55
+ i [`llms-pl.txt`](https://ui.konce.pt/llms/theme/llms-pl.txt) (PL).
56
+
57
+ ## Testy
58
+
59
+ ```bash
60
+ pnpm --filter @konce-pt/theme exec node --test "src/**/*.test.ts"
61
+ ```
62
+
63
+ ## Licencja
64
+
65
+ MIT © [konce.pt](https://konce.pt)
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Audyt kontrastu.
3
+ *
4
+ * Mierzymy pary, które realnie występują w bibliotece — tekst na powierzchni, treść na wypełnieniu
5
+ * akcentowym, kreska na tle — a nie iloczyn kartezjański wszystkich tokenów. Lista jest krótka
6
+ * i konkretna, bo audyt ma wskazać miejsce do poprawienia, a nie wysypać sto liczb.
7
+ *
8
+ * Obie miary idą obok siebie: WCAG 2.1, bo na nim stoją wymagania formalne, i APCA, bo lepiej
9
+ * odpowiada temu, co widać, i uwzględnia biegunowość. Para może zdać jedną i oblać drugą — wtedy
10
+ * warto to zobaczyć, a nie uśredniać.
11
+ */
12
+ import type { KptThemeDoc, KptThemeMode } from './schema.ts';
13
+ /**
14
+ * Poziom wymagania: tekst podstawowy, tekst duży, element interfejsu, element dekoracyjny.
15
+ *
16
+ * `decorative` **nie jest** progiem WCAG i celowo nie udaje, że jest. Zwykła kreska rozdzielająca
17
+ * nie jest „komponentem interfejsu" w rozumieniu 1.4.11 i praktycznie żaden system projektowy nie
18
+ * trzyma jej na 3:1 — wymaganie tego produkowałoby ostrzeżenie, które każdy nauczy się ignorować,
19
+ * a razem z nim ignorować całą resztę. Pytanie jest tu inne i węższe: czy kreska w ogóle widać.
20
+ */
21
+ export type KptContrastLevel = 'body' | 'large' | 'ui' | 'decorative';
22
+ export interface KptContrastPair {
23
+ text: string;
24
+ background: string;
25
+ level: KptContrastLevel;
26
+ /** Po czym poznać tę parę w interfejsie — co to jest na ekranie. */
27
+ label: string;
28
+ }
29
+ /**
30
+ * Pary sprawdzane w obu motywach. `text` nie zawsze jest literami: dla `ui` to bywa kreska albo
31
+ * pierścień focus, czyli rzecz, która ma się odciąć od tła.
32
+ */
33
+ export declare const KPT_CONTRAST_PAIRS: readonly KptContrastPair[];
34
+ export interface KptContrastFinding extends KptContrastPair {
35
+ mode: KptThemeMode;
36
+ /** Wartości po rozwiązaniu — żeby interfejs mógł pokazać, co dokładnie zmierzono. */
37
+ textValue: string;
38
+ backgroundValue: string;
39
+ ratio: number;
40
+ lc: number;
41
+ requiredRatio: number;
42
+ requiredLc: number;
43
+ passesWcag: boolean;
44
+ passesApca: boolean;
45
+ /**
46
+ * Czy ta sama para nie zdaje już w motywie stockowym biblioteki. Edytor domyślnie chowa takie
47
+ * wyniki: użytkownik ma zobaczyć, co **on** popsuł, a nie odziedziczyć listy, na którą nie ma
48
+ * wpływu — ale ukryć to zupełnie też nie wolno, bo część z tych par to prawdziwe problemy
49
+ * (biały tekst na wypełnieniu ostrzeżenia trzyma 3,19 przy wymaganych 4,5).
50
+ */
51
+ inherited: boolean;
52
+ }
53
+ export interface KptAuditOptions {
54
+ /** Zwróć także pary, które zdały. Domyślnie wracają same problemy. */
55
+ all?: boolean;
56
+ /** Ogranicz do jednego motywu. Domyślnie sprawdzane są oba. */
57
+ mode?: KptThemeMode;
58
+ }
59
+ export declare function auditTheme(doc?: KptThemeDoc, options?: KptAuditOptions): KptContrastFinding[];
package/dist/audit.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Audyt kontrastu.
3
+ *
4
+ * Mierzymy pary, które realnie występują w bibliotece — tekst na powierzchni, treść na wypełnieniu
5
+ * akcentowym, kreska na tle — a nie iloczyn kartezjański wszystkich tokenów. Lista jest krótka
6
+ * i konkretna, bo audyt ma wskazać miejsce do poprawienia, a nie wysypać sto liczb.
7
+ *
8
+ * Obie miary idą obok siebie: WCAG 2.1, bo na nim stoją wymagania formalne, i APCA, bo lepiej
9
+ * odpowiada temu, co widać, i uwzględnia biegunowość. Para może zdać jedną i oblać drugą — wtedy
10
+ * warto to zobaczyć, a nie uśredniać.
11
+ */
12
+ import { apcaLc, contrastRatio, oklchToSrgb, parseOklch } from '@konce-pt/color';
13
+ import { resolveTheme } from "./resolve.js";
14
+ const THRESHOLDS = {
15
+ body: { ratio: 4.5, lc: 75 },
16
+ large: { ratio: 3, lc: 60 },
17
+ ui: { ratio: 3, lc: 45 },
18
+ decorative: { ratio: 1.2, lc: 8 },
19
+ };
20
+ /**
21
+ * Pary sprawdzane w obu motywach. `text` nie zawsze jest literami: dla `ui` to bywa kreska albo
22
+ * pierścień focus, czyli rzecz, która ma się odciąć od tła.
23
+ */
24
+ export const KPT_CONTRAST_PAIRS = [
25
+ { text: '--kpt-color-on-surface', background: '--kpt-color-surface', level: 'body', label: 'Tekst główny na stronie' },
26
+ { text: '--kpt-color-on-surface', background: '--kpt-color-surface-raised', level: 'body', label: 'Tekst na karcie' },
27
+ { text: '--kpt-color-on-surface', background: '--kpt-color-surface-variant', level: 'body', label: 'Tekst w nagłówku tabeli' },
28
+ { text: '--kpt-color-on-surface', background: '--kpt-color-surface-sunken', level: 'body', label: 'Tekst w sekcji zagłębionej' },
29
+ { text: '--kpt-color-on-surface-muted', background: '--kpt-color-surface', level: 'body', label: 'Tekst drugorzędny' },
30
+ { text: '--kpt-color-text-subtle', background: '--kpt-color-surface', level: 'ui', label: 'Placeholder i tekst wyłączony' },
31
+ { text: '--kpt-color-on-primary', background: '--kpt-color-primary', level: 'body', label: 'Etykieta przycisku głównego' },
32
+ { text: '--kpt-color-on-muted', background: '--kpt-color-muted', level: 'body', label: 'Etykieta przycisku tonalnego' },
33
+ { text: '--kpt-color-danger-contrast', background: '--kpt-color-danger', level: 'body', label: 'Treść na wypełnieniu błędu' },
34
+ { text: '--kpt-color-success-contrast', background: '--kpt-color-success', level: 'body', label: 'Treść na wypełnieniu powodzenia' },
35
+ { text: '--kpt-color-warning-contrast', background: '--kpt-color-warning', level: 'body', label: 'Treść na wypełnieniu ostrzeżenia' },
36
+ { text: '--kpt-color-info-contrast', background: '--kpt-color-info', level: 'body', label: 'Treść na wypełnieniu informacji' },
37
+ { text: '--kpt-color-on-surface', background: '--kpt-color-primary-subtle', level: 'body', label: 'Tekst na badge akcentu' },
38
+ { text: '--kpt-color-on-surface', background: '--kpt-color-danger-subtle', level: 'body', label: 'Tekst w alercie błędu' },
39
+ { text: '--kpt-color-on-surface', background: '--kpt-color-success-subtle', level: 'body', label: 'Tekst w alercie powodzenia' },
40
+ { text: '--kpt-color-on-surface', background: '--kpt-color-warning-subtle', level: 'body', label: 'Tekst w alercie ostrzeżenia' },
41
+ { text: '--kpt-color-on-surface', background: '--kpt-color-info-subtle', level: 'body', label: 'Tekst w alercie informacji' },
42
+ { text: '--kpt-color-border', background: '--kpt-color-surface', level: 'decorative', label: 'Kreska rozdzielająca' },
43
+ { text: '--kpt-color-border-strong', background: '--kpt-color-surface', level: 'ui', label: 'Obrys pola formularza' },
44
+ { text: '--kpt-color-focus-ring', background: '--kpt-color-surface', level: 'ui', label: 'Pierścień focus' },
45
+ { text: '--kpt-color-primary', background: '--kpt-color-surface', level: 'ui', label: 'Ikona i tekst akcentowy' },
46
+ ];
47
+ const toRgb = (value) => {
48
+ const oklch = parseOklch(value);
49
+ return oklch ? oklchToSrgb(oklch) : null;
50
+ };
51
+ /**
52
+ * Pary o zbyt niskim kontraście, posortowane od najgorszej.
53
+ *
54
+ * Pary, których nie da się zmierzyć — bo wartość jest półprzezroczysta, jest `color-mix()` albo
55
+ * czymś, czego nie umiemy sprowadzić do składowych — są **pomijane**, nie zaliczane. Liczba
56
+ * wzięta z niepewnej wartości byłaby gorsza niż jej brak: audyt mówiłby „zdane" tam, gdzie nic
57
+ * nie sprawdził.
58
+ */
59
+ const keyOf = (mode, pair) => `${mode}|${pair.text}|${pair.background}`;
60
+ /** Pary nie zdające już w stocku. Liczone raz i bez flagi `inherited`, żeby nie zapętlić audytu. */
61
+ let stockFailures;
62
+ const inheritedFailures = () => (stockFailures ??= new Set(measure(undefined).filter((f) => !f.passesWcag || !f.passesApca).map((f) => f.key)));
63
+ export function auditTheme(doc, options = {}) {
64
+ const baseline = inheritedFailures();
65
+ return measure(doc, options)
66
+ .map(({ key, ...finding }) => ({ ...finding, inherited: baseline.has(key) }))
67
+ .filter((finding) => options.all || !finding.passesWcag || !finding.passesApca)
68
+ .sort((a, b) => a.ratio - b.ratio);
69
+ }
70
+ function measure(doc, options = {}) {
71
+ const resolved = resolveTheme(doc);
72
+ const modes = options.mode ? [options.mode] : ['light', 'dark'];
73
+ const findings = [];
74
+ for (const mode of modes) {
75
+ const values = resolved[mode];
76
+ for (const pair of KPT_CONTRAST_PAIRS) {
77
+ const textValue = values[pair.text];
78
+ const backgroundValue = values[pair.background];
79
+ const text = textValue ? toRgb(textValue) : null;
80
+ const background = backgroundValue ? toRgb(backgroundValue) : null;
81
+ if (!text || !background)
82
+ continue;
83
+ const threshold = THRESHOLDS[pair.level];
84
+ const ratio = contrastRatio(text, background);
85
+ const lc = apcaLc(text, background);
86
+ findings.push({
87
+ ...pair,
88
+ key: keyOf(mode, pair),
89
+ mode,
90
+ textValue,
91
+ backgroundValue,
92
+ ratio: Number(ratio.toFixed(2)),
93
+ lc: Number(lc.toFixed(1)),
94
+ requiredRatio: threshold.ratio,
95
+ requiredLc: threshold.lc,
96
+ passesWcag: ratio >= threshold.ratio,
97
+ passesApca: Math.abs(lc) >= threshold.lc,
98
+ });
99
+ }
100
+ }
101
+ return findings;
102
+ }
package/dist/dtcg.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Import z formatu W3C Design Tokens (DTCG) — jednokierunkowy, plikowy.
3
+ *
4
+ * To jest import, nie synchronizacja, i tak trzeba go nazywać. Odczyt zmiennych z narzędzia
5
+ * projektowego przez API bywa zarezerwowany dla planów firmowych, więc realną drogą jest plik
6
+ * wyeksportowany wtyczką. Nie ma tu OAuth, nie ma odpytywania, nie ma śledzenia zmian.
7
+ *
8
+ * Najtrudniejsze nie jest samo czytanie tokenów, tylko **tryby**: DTCG ich nie standaryzuje.
9
+ * Eksportery kodują je na trzy sposoby i każdy trzeba obsłużyć osobno — patrz `splitModes`.
10
+ *
11
+ * Zasada, od której nie ma odstępstwa: **importer nigdy niczego nie gubi po cichu.** Token, którego
12
+ * nie umiemy przypisać, trafia do raportu z powodem. Lepiej pokazać listę stu niedopasowań niż
13
+ * wczytać trzydzieści i nie powiedzieć o siedemdziesięciu.
14
+ */
15
+ import type { KptThemeDoc, KptThemeMode } from './schema.ts';
16
+ /** Jeden plik wejściowy. Nazwa bywa jedyną informacją o trybie, więc idzie razem z treścią. */
17
+ export interface KptDtcgSource {
18
+ name: string;
19
+ content: unknown;
20
+ }
21
+ export interface KptDtcgOptions {
22
+ /**
23
+ * Ręczne wskazanie, co jest którym trybem — nazwa pliku albo grupy najwyższego poziomu.
24
+ * Podane, wygrywa z heurystyką; to jest wyjście awaryjne dla eksporterów, których nie znamy.
25
+ */
26
+ modes?: {
27
+ light?: string;
28
+ dark?: string;
29
+ };
30
+ /**
31
+ * Utrwalone mapowanie ze ścieżki DTCG na nazwę tokenu (`meta.importMap`). To ono zamienia
32
+ * jednorazowy import w element procesu pracy: po zmianie brandbooka wystarczy wczytać plik.
33
+ */
34
+ map?: Readonly<Record<string, string>>;
35
+ }
36
+ export type KptDtcgConfidence = 'exact' | 'synonym' | 'manual';
37
+ export interface KptDtcgReport {
38
+ /** Dopasowane tokeny — `from` to ścieżka DTCG, `to` nazwa custom property. */
39
+ matched: {
40
+ from: string;
41
+ to: string;
42
+ mode: KptThemeMode;
43
+ confidence: KptDtcgConfidence;
44
+ }[];
45
+ /** Tokeny, których nie przypisaliśmy. Każdy z powodem — to jest sedno raportu. */
46
+ unmapped: {
47
+ path: string;
48
+ type: string;
49
+ value: string;
50
+ reason: KptDtcgReason;
51
+ }[];
52
+ /** Role biblioteki, których plik nie pokrył. Zostają na wartościach stockowych. */
53
+ missing: string[];
54
+ warnings: {
55
+ code: KptDtcgWarning;
56
+ detail: string;
57
+ }[];
58
+ /** Rampy rozpoznane w pliku — z nich bierze się ziarno koloru wiodącego. */
59
+ ramps: {
60
+ path: string;
61
+ steps: number;
62
+ seed?: string;
63
+ }[];
64
+ }
65
+ export type KptDtcgReason = 'no-target' | 'unsupported-type' | 'composite' | 'unreadable-value'
66
+ /** Token nasz, ale **pochodny** — podąża za semantyką, więc nadpisanie zamroziłoby go. */
67
+ | 'derived';
68
+ export type KptDtcgWarning = 'mode-guess' | 'no-modes' | 'alias-broken' | 'legacy-format' | 'empty';
69
+ /** Nazwa ze ścieżki DTCG w kształcie, w którym da się ją porównać z nazwą roli. */
70
+ export declare function normalizeDtcgPath(path: string): string;
71
+ /**
72
+ * Czyta pliki DTCG i buduje z nich dokument motywu.
73
+ *
74
+ * Zwraca **parę**: motyw i raport. Raport nie jest dodatkiem — to on decyduje, czy import da się
75
+ * przyjąć, i to on pokazuje, czego plik nie pokrył. Interfejs ma go wyświetlić, a nie schować.
76
+ */
77
+ export declare function fromDtcg(sources: readonly KptDtcgSource[], options?: KptDtcgOptions): {
78
+ theme: KptThemeDoc;
79
+ report: KptDtcgReport;
80
+ };