@netzi/tokens 0.1.0-beta.10

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,10 @@
1
+ import type { NETZI_BRAND_CONTEXT } from './brand.js';
2
+ import type { Rgba } from './contrast.js';
3
+
4
+ /** Superficies, alfas de los tintes y tintas con que se mide una marca. */
5
+ export type BrandContext = typeof NETZI_BRAND_CONTEXT;
6
+
7
+ export declare function parseBrandColor(color: unknown): string | null;
8
+ export declare function rgba(hex: unknown, name: string): Rgba;
9
+ /** Si un relleno aguanta una tinta del kit y los textos de sus tintes, en los dos modos. */
10
+ export declare function fillPasses(fill: Rgba, ctx: BrandContext): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Lo que la marca mide contra un CONTEXTO (superficies, alfas de los tintes y tintas del kit):
3
+ * las piezas de `brand.js` que no dependen de los valores del tema por defecto, que se le
4
+ * pasan. Así se prueban con superficies en las que el contrato muerde de verdad. Es interno:
5
+ * no está en los `exports` del paquete; lo público es `@netzi/tokens/brand`.
6
+ */
7
+ import { MINIMUM, contrastRatio, over } from './contrast.js';
8
+
9
+ const HEX = /^#(?:[\da-f]{3}|[\da-f]{6})$/i;
10
+ const MODES = Object.freeze(['light', 'dark']);
11
+
12
+ /** `#rgb` o `#rrggbb` (sin alfa: la marca es un relleno opaco) → `#rrggbb` en minúsculas. */
13
+ export function parseBrandColor(color) {
14
+ if (typeof color !== 'string') return null;
15
+ const hex = color.trim();
16
+ if (!HEX.test(hex)) return null;
17
+ const digits = hex.length === 4 ? hex.slice(1).replaceAll(/./g, (d) => d + d) : hex.slice(1);
18
+ return `#${digits.toLowerCase()}`;
19
+ }
20
+
21
+ /** El color en canales de 8 bits; `TypeError` con el nombre de lo que no es un color. */
22
+ export function rgba(hex, name) {
23
+ const normal = parseBrandColor(hex);
24
+ if (!normal) throw new TypeError(`${name}: «${hex}» no es un color #rgb o #rrggbb`);
25
+ const n = Number.parseInt(normal.slice(1), 16);
26
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];
27
+ }
28
+
29
+ /** Las superficies del modo, opacas. */
30
+ export const surfacesOf = (ctx, mode) =>
31
+ Object.entries(ctx.surfaces[mode]).map(([name, hex]) => ({ name, color: rgba(hex, name) }));
32
+
33
+ /** El tinte del primario (`soft` o `strong`) de un modo: el relleno con su alfa. */
34
+ export const tintOf = (ctx, primary, mode, kind) => ({
35
+ name: kind === 'soft' ? '--netzi-primary-tint' : '--netzi-primary-tint-strong',
36
+ color: [primary[0], primary[1], primary[2], ctx.tintAlpha[mode][kind]],
37
+ });
38
+
39
+ /** Un tinte translúcido compuesto sobre cada superficie del modo. */
40
+ export const tintedOf = (ctx, mode, tint) =>
41
+ surfacesOf(ctx, mode).map((s) => ({
42
+ name: `${tint.name} sobre ${s.name}`,
43
+ color: over(tint.color, s.color),
44
+ }));
45
+
46
+ /**
47
+ * Lo que un relleno tiene que aguantar en los dos modos: alguna de las tintas del kit a 4.5:1
48
+ * encima, `--netzi-text` a 4.5:1 sobre su tinte marcado y el pulgar de las barras
49
+ * (`--netzi-text-2`) a 3:1 sobre el suave. Lo segundo hoy no muerde con las superficies del
50
+ * kit (el peor caso, un relleno blanco en oscuro, da 3.03:1), pero es parte del contrato.
51
+ */
52
+ export function fillPasses(fill, ctx) {
53
+ const inks = Object.entries(ctx.ink).map(([k, hex]) => rgba(hex, `ink.${k}`));
54
+ if (Math.max(...inks.map((i) => contrastRatio(i, fill))) < MINIMUM.text) return false;
55
+ return MODES.every((mode) => {
56
+ const text = rgba(ctx.inksOnTint[mode].text, '--netzi-text');
57
+ const text2 = rgba(ctx.inksOnTint[mode]['text-2'], '--netzi-text-2');
58
+ const strong = tintedOf(ctx, mode, tintOf(ctx, fill, mode, 'strong'));
59
+ const soft = tintedOf(ctx, mode, tintOf(ctx, fill, mode, 'soft'));
60
+ return (
61
+ strong.every((b) => contrastRatio(text, b.color) >= MINIMUM.text) &&
62
+ soft.every((b) => contrastRatio(text2, b.color) >= MINIMUM['non-text'])
63
+ );
64
+ });
65
+ }
package/lib/brand.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ /** Modo de color en que se mide una marca. */
2
+ export type NetziBrandMode = 'light' | 'dark';
3
+
4
+ /**
5
+ * Los tokens del primario de UN modo, en `#rrggbb`. Son los que se redefinen juntos al poner
6
+ * una marca propia: cada uno se pinta encima o debajo de otro, y cambiar uno solo rompe el
7
+ * par. Los tintes (`--netzi-primary-tint`, `-strong`) y el anillo de foco salen solos de
8
+ * `primary` y `primaryText`.
9
+ */
10
+ export interface NetziBrandTokens {
11
+ /** `--netzi-primary`: el relleno de marca (botón primario, interruptor, barra). */
12
+ primary: string;
13
+ /** `--netzi-primary-hover`: el relleno al pasar el puntero, con la misma tinta encima. */
14
+ primaryHover: string;
15
+ /**
16
+ * `--netzi-primary-pressed`: el relleno presionado (#156), el hover con la capa de presionado
17
+ * del color que se aleja de la tinta. 4.5:1 con la misma tinta.
18
+ */
19
+ primaryPressed: string;
20
+ /** `--netzi-primary-dark`: el relleno de lo marcado (checkbox, radio), con blanco encima. */
21
+ primaryDark: string;
22
+ /** `--netzi-primary-text`: el primario que se LEE (enlaces, ítem activo). 4.5:1. */
23
+ primaryText: string;
24
+ /** `--netzi-primary-graphic`: lo no textual que comunica (progreso, anillo). 3:1. */
25
+ primaryGraphic: string;
26
+ /** `--netzi-on-primary`: la tinta sobre el relleno y su hover. 4.5:1. */
27
+ onPrimary: string;
28
+ /** `--netzi-on-primary-dark`: la tinta sobre el primario oscuro. 3:1. */
29
+ onPrimaryDark: string;
30
+ }
31
+
32
+ /** Una marca completa: sus tokens en claro y en oscuro. */
33
+ export interface NetziBrandTheme {
34
+ light: NetziBrandTokens;
35
+ dark: NetziBrandTokens;
36
+ }
37
+
38
+ /** Lo que devuelve `deriveBrand`: la marca pedida y la paleta que cumple el contrato. */
39
+ export interface NetziBrand extends NetziBrandTheme {
40
+ /** El color pedido, normalizado a `#rrggbb`. */
41
+ color: string;
42
+ /**
43
+ * `true` si el color no podía ser el relleno (ninguna tinta llegaba a 4.5:1 encima, o
44
+ * tumbaba los textos de su tinte) y `primary` es el tono más cercano que sí.
45
+ */
46
+ adjusted: boolean;
47
+ }
48
+
49
+ /** Un par del contrato medido: una tinta sobre un fondo, en un modo. */
50
+ export interface NetziBrandCheck {
51
+ mode: NetziBrandMode;
52
+ /** La custom property que se pinta encima (`--netzi-primary-text`). */
53
+ token: string;
54
+ /** El fondo: una superficie, un tinte compuesto sobre ella o un relleno. */
55
+ on: string;
56
+ ratio: number;
57
+ /** 4.5 para texto (WCAG 1.4.3), 3 para lo no textual (WCAG 1.4.11). */
58
+ minimum: number;
59
+ ok: boolean;
60
+ }
61
+
62
+ /** El veredicto del validador: `ok` si cumplen todos los pares. */
63
+ export interface NetziBrandReport {
64
+ ok: boolean;
65
+ checks: NetziBrandCheck[];
66
+ failures: NetziBrandCheck[];
67
+ }
68
+
69
+ /** Cada token de marca y su custom property (`primaryText` → `--netzi-primary-text`). */
70
+ export declare const NETZI_BRAND_TOKENS: Readonly<Record<keyof NetziBrandTokens, string>>;
71
+
72
+ /** Las superficies, tintes y tintas con que mide el validador (las del tema por defecto). */
73
+ export declare const NETZI_BRAND_CONTEXT: Readonly<{
74
+ surfaces: Readonly<Record<NetziBrandMode, Readonly<Record<string, string>>>>;
75
+ tintAlpha: Readonly<Record<NetziBrandMode, Readonly<{ soft: number; strong: number }>>>;
76
+ inksOnTint: Readonly<Record<NetziBrandMode, Readonly<{ text: string; 'text-2': string }>>>;
77
+ ink: Readonly<{ dark: string; light: string }>;
78
+ }>;
79
+
80
+ /** `#rgb` o `#rrggbb` → `#rrggbb` en minúsculas; `null` si no es un color así. */
81
+ export declare function parseBrandColor(color: unknown): string | null;
82
+
83
+ /** Comprueba una marca contra el contrato en claro y en oscuro; `TypeError` si falta un modo. */
84
+ export declare function validateBrand(brand: NetziBrandTheme): NetziBrandReport;
85
+
86
+ /** Deriva de un color de marca los tokens del primario que cumplen el contrato. */
87
+ export declare function deriveBrand(color: string): NetziBrand;
88
+
89
+ /** Los tokens de un modo como custom properties, listos para aplicar. */
90
+ export declare function brandVariables(tokens: NetziBrandTokens): Record<string, string>;
package/lib/brand.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Marca propia con contraste (#174): el contrato de los tokens del primario, un validador y
3
+ * la derivación de una paleta que lo cumple a partir de UN color. Se publica como
4
+ * `@netzi/tokens/brand`, en JavaScript plano y sin dependencias para que valga en cualquier
5
+ * framework; los tipos van en `brand.d.ts`.
6
+ *
7
+ * El contrato es el mismo que declara la fuente DTCG para el primario (texto a 4.5:1, lo no
8
+ * textual a 3:1, sobre cada superficie y sobre el tinte del primario), con las superficies,
9
+ * tintes y tintas del tema por defecto en claro y en oscuro. Un test de `@netzi/tokens` mete
10
+ * lo derivado en la fuente y pasa el contrato del build: si uno cambia sin el otro, se rompe.
11
+ */
12
+ import {
13
+ fillPasses,
14
+ rgba,
15
+ surfacesOf as surfacesIn,
16
+ tintOf as tintIn,
17
+ tintedOf as tintedIn,
18
+ } from './brand-context.js';
19
+ import { MINIMUM, contrastRatio } from './contrast.js';
20
+
21
+ export { parseBrandColor } from './brand-context.js';
22
+
23
+ /** Superficies del tema por defecto: todo token con papel de superficie en la fuente. */
24
+ const SURFACES = Object.freeze({
25
+ light: Object.freeze({
26
+ '--netzi-bg': '#f7f9fb',
27
+ '--netzi-surface': '#ffffff',
28
+ '--netzi-surface-2': '#f4f6f8',
29
+ '--netzi-surface-3': '#eef1f4',
30
+ }),
31
+ dark: Object.freeze({
32
+ '--netzi-bg': '#0d1117',
33
+ '--netzi-surface': '#161b22',
34
+ '--netzi-surface-2': '#0d1117',
35
+ '--netzi-surface-3': '#21262d',
36
+ }),
37
+ });
38
+
39
+ /** Alfa de los tintes del primario (`--netzi-primary-tint` y `-strong`) en cada modo. */
40
+ const TINT_ALPHA = Object.freeze({
41
+ light: Object.freeze({ soft: 0.1, strong: 0.22 }),
42
+ dark: Object.freeze({ soft: 0.15, strong: 0.28 }),
43
+ });
44
+
45
+ /**
46
+ * Los textos del kit que se pintan sobre los tintes del primario: `--netzi-text` sobre el
47
+ * marcado y `--netzi-text-2` (el pulgar de las barras de desplazamiento) sobre el suave. No
48
+ * son de la marca, pero dependen de ella: una marca puede tumbarlos a través de su tinte.
49
+ */
50
+ const INKS_ON_TINT = Object.freeze({
51
+ light: Object.freeze({ text: '#1c252e', 'text-2': '#5c6b78' }),
52
+ dark: Object.freeze({ text: '#ffffff', 'text-2': '#8b949e' }),
53
+ });
54
+
55
+ /** Las dos tintas que el kit pone sobre un relleno del primario. */
56
+ const INK = Object.freeze({ dark: '#04121f', light: '#ffffff' });
57
+
58
+ /** El paso mínimo del hover frente al reposo: que se note el cambio sin perder la tinta. */
59
+ const HOVER_STEP = 1.2;
60
+
61
+ /**
62
+ * La opacidad de la capa de presionado (`state.pressed.opacity` de la fuente, #156): el
63
+ * presionado es el hover con esa capa del color que se aleja de su tinta.
64
+ */
65
+ const PRESSED_OPACITY = 0.12;
66
+
67
+ /** Cada token de marca y su custom property, en el orden en que se aplican y se documentan. */
68
+ export const NETZI_BRAND_TOKENS = Object.freeze({
69
+ primary: '--netzi-primary',
70
+ primaryHover: '--netzi-primary-hover',
71
+ primaryPressed: '--netzi-primary-pressed',
72
+ primaryDark: '--netzi-primary-dark',
73
+ primaryText: '--netzi-primary-text',
74
+ primaryGraphic: '--netzi-primary-graphic',
75
+ onPrimary: '--netzi-on-primary',
76
+ onPrimaryDark: '--netzi-on-primary-dark',
77
+ });
78
+
79
+ const CSS_NAMES = NETZI_BRAND_TOKENS;
80
+ const KEYS = Object.freeze(Object.keys(CSS_NAMES));
81
+
82
+ const MODES = Object.freeze(['light', 'dark']);
83
+ const BLACK = Object.freeze([0, 0, 0, 1]);
84
+ const WHITE = Object.freeze([255, 255, 255, 1]);
85
+ const twoDigits = (n) => n.toString(16).padStart(2, '0');
86
+ const toHex = ([r, g, b]) => `#${twoDigits(r)}${twoDigits(g)}${twoDigits(b)}`;
87
+
88
+ /** `color-mix(in srgb, color, hacia t)`, redondeado a 8 bits. */
89
+ function mix(color, toward, t) {
90
+ const canal = (i) => Math.round(color[i] + (toward[i] - color[i]) * t);
91
+ return [canal(0), canal(1), canal(2), 1];
92
+ }
93
+
94
+ /**
95
+ * El primer tono que cumple `passes` yendo de `base` hacia cada extremo de `towards` en pasos
96
+ * del 1 %, todos los sentidos a la vez: el más cercano, con empate para el primero de la lista.
97
+ */
98
+ function nearest(base, towards, passes) {
99
+ for (let step = 0; step <= 100; step++) {
100
+ for (const toward of towards) {
101
+ const candidate = mix(base, toward, step / 100);
102
+ if (passes(candidate)) return candidate;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * El tono más cercano a `base` que cumple `passes`, oscureciendo o aclarando. Los extremos
110
+ * (negro y blanco) cumplen cualquier contrato del kit, así que siempre hay respuesta.
111
+ */
112
+ function closest(base, prefer, passes) {
113
+ const found = nearest(base, prefer === 'darker' ? [BLACK, WHITE] : [WHITE, BLACK], passes);
114
+ if (!found) throw new Error(`Ningún tono de ${toHex(base)} cumple el contrato`);
115
+ return found;
116
+ }
117
+
118
+ /** El contexto del tema por defecto, ya aplicado. */
119
+ const surfacesOf = (mode) => surfacesIn(NETZI_BRAND_CONTEXT, mode);
120
+ const tintedOf = (mode, tint) => tintedIn(NETZI_BRAND_CONTEXT, mode, tint);
121
+ const tintOf = (primary, mode, kind) => tintIn(NETZI_BRAND_CONTEXT, primary, mode, kind);
122
+
123
+ /** Los pares del contrato de un modo: cada tinta con su papel y sus fondos. */
124
+ function pairsOf(mode, tokens) {
125
+ const c = Object.fromEntries(KEYS.map((k) => [k, rgba(tokens[k], CSS_NAMES[k])]));
126
+ const soft = tintOf(c.primary, mode, 'soft');
127
+ const strong = tintOf(c.primary, mode, 'strong');
128
+ const ink = (name, color, role, on) => ({ name, color, role, on });
129
+ return [
130
+ ink(CSS_NAMES.primaryText, c.primaryText, 'text', [
131
+ ...surfacesOf(mode),
132
+ ...tintedOf(mode, soft),
133
+ ]),
134
+ ink(CSS_NAMES.primaryGraphic, c.primaryGraphic, 'non-text', [
135
+ ...surfacesOf(mode),
136
+ ...tintedOf(mode, soft),
137
+ ]),
138
+ ink(CSS_NAMES.primaryDark, c.primaryDark, 'non-text', surfacesOf(mode)),
139
+ ink(CSS_NAMES.onPrimaryDark, c.onPrimaryDark, 'non-text', [
140
+ { name: CSS_NAMES.primaryDark, color: c.primaryDark },
141
+ ]),
142
+ ink(CSS_NAMES.onPrimary, c.onPrimary, 'text', [
143
+ { name: CSS_NAMES.primary, color: c.primary },
144
+ { name: CSS_NAMES.primaryHover, color: c.primaryHover },
145
+ { name: CSS_NAMES.primaryPressed, color: c.primaryPressed },
146
+ ]),
147
+ ink(
148
+ '--netzi-text',
149
+ rgba(INKS_ON_TINT[mode].text, '--netzi-text'),
150
+ 'text',
151
+ tintedOf(mode, strong),
152
+ ),
153
+ ink(
154
+ '--netzi-scrollbar-thumb',
155
+ rgba(INKS_ON_TINT[mode]['text-2'], '--netzi-text-2'),
156
+ 'non-text',
157
+ tintedOf(mode, soft),
158
+ ),
159
+ ];
160
+ }
161
+
162
+ /**
163
+ * Comprueba una marca contra el contrato, en claro y en oscuro. Cada par lleva su ratio y su
164
+ * mínimo; `ok` es que cumplen todos. Lanza `TypeError` si falta un modo o un token no es un
165
+ * color.
166
+ */
167
+ export function validateBrand(brand) {
168
+ const checks = [];
169
+ for (const mode of MODES) {
170
+ if (!brand?.[mode]) throw new TypeError(`validateBrand: falta el modo «${mode}»`);
171
+ for (const pair of pairsOf(mode, brand[mode])) {
172
+ const minimum = MINIMUM[pair.role];
173
+ for (const bg of pair.on) {
174
+ const ratio = contrastRatio(pair.color, bg.color);
175
+ checks.push({ mode, token: pair.name, on: bg.name, ratio, minimum, ok: ratio >= minimum });
176
+ }
177
+ }
178
+ }
179
+ const failures = checks.filter((c) => !c.ok);
180
+ return { ok: failures.length === 0, checks, failures };
181
+ }
182
+
183
+ /** Una tinta que llega a `minimum` sobre todos los fondos. */
184
+ const reaches = (minimum, on) => (color) =>
185
+ on.every((b) => contrastRatio(color, b.color) >= minimum);
186
+
187
+ /**
188
+ * Deriva de un color de marca los tokens del primario que cumplen el contrato en claro y en
189
+ * oscuro. El relleno es el color tal cual mientras alguna tinta llegue a 4.5:1 encima; si
190
+ * ninguna llega (los tonos medios), se mueve al más cercano que sí y `adjusted` lo dice. El
191
+ * texto, el gráfico y el primario oscuro son el tono más cercano al relleno que llega a su
192
+ * mínimo en cada modo. Lanza `TypeError` si el color no es `#rgb` ni `#rrggbb`.
193
+ */
194
+ export function deriveBrand(color) {
195
+ const source = rgba(color, 'deriveBrand');
196
+ const primary = closest(source, 'darker', (f) => fillPasses(f, NETZI_BRAND_CONTEXT));
197
+ const [onPrimary] = Object.values(INK)
198
+ .map((i) => rgba(i, 'INK'))
199
+ .sort((a, b) => contrastRatio(b, primary) - contrastRatio(a, primary));
200
+ // El hover OSCURECE, como en los presets; solo si con su tinta no puede, aclara.
201
+ const hoverPasses = (h) =>
202
+ contrastRatio(onPrimary, h) >= MINIMUM.text && contrastRatio(primary, h) >= HOVER_STEP;
203
+ const hover = nearest(primary, [BLACK], hoverPasses) ?? closest(primary, 'lighter', hoverPasses);
204
+ const pressed = pressedOf(hover, onPrimary);
205
+ const white = rgba(INK.light, 'INK');
206
+ const tokensOf = (mode) => {
207
+ const prefer = mode === 'light' ? 'darker' : 'lighter';
208
+ const conTinte = [...surfacesOf(mode), ...tintedOf(mode, tintOf(primary, mode, 'soft'))];
209
+ const onSurfaces = reaches(MINIMUM['non-text'], surfacesOf(mode));
210
+ const dark = closest(primary, 'darker', (d) => {
211
+ return onSurfaces(d) && contrastRatio(white, d) >= MINIMUM['non-text'];
212
+ });
213
+ return {
214
+ primary: toHex(primary),
215
+ primaryHover: toHex(hover),
216
+ primaryPressed: toHex(pressed),
217
+ primaryDark: toHex(dark),
218
+ primaryText: toHex(closest(primary, prefer, reaches(MINIMUM.text, conTinte))),
219
+ primaryGraphic: toHex(closest(primary, prefer, reaches(MINIMUM['non-text'], conTinte))),
220
+ onPrimary: toHex(onPrimary),
221
+ onPrimaryDark: INK.light,
222
+ };
223
+ };
224
+ return {
225
+ color: toHex(source),
226
+ adjusted: toHex(primary) !== toHex(source),
227
+ light: tokensOf('light'),
228
+ dark: tokensOf('dark'),
229
+ };
230
+ }
231
+
232
+ /**
233
+ * El presionado (#156): el hover con la capa de presionado del color que se aleja de la tinta
234
+ * (blanco bajo una tinta oscura, negro bajo una blanca), así que la tinta gana contraste en vez
235
+ * de perderlo. Si aun así no llegara a 4.5:1, el tono más cercano que sí.
236
+ */
237
+ function pressedOf(hover, onPrimary) {
238
+ const away = contrastRatio(onPrimary, BLACK) > contrastRatio(onPrimary, WHITE) ? BLACK : WHITE;
239
+ const pressed = mix(hover, away, PRESSED_OPACITY);
240
+ const passes = (p) => contrastRatio(onPrimary, p) >= MINIMUM.text;
241
+ if (passes(pressed)) return pressed;
242
+ return closest(pressed, away === WHITE ? 'lighter' : 'darker', passes);
243
+ }
244
+
245
+ /** Los tokens de un modo como custom properties (`--netzi-primary` → valor), para aplicarlos. */
246
+ export function brandVariables(tokens) {
247
+ return Object.fromEntries(KEYS.map((k) => [CSS_NAMES[k], tokens[k]]));
248
+ }
249
+
250
+ /** Las superficies, tintes y tintas con que mide el validador (las del tema por defecto). */
251
+ export const NETZI_BRAND_CONTEXT = Object.freeze({
252
+ surfaces: SURFACES,
253
+ tintAlpha: TINT_ALPHA,
254
+ inksOnTint: INKS_ON_TINT,
255
+ ink: INK,
256
+ });
@@ -0,0 +1,14 @@
1
+ /** Canales 0-255 y alfa 0-1. */
2
+ export type Rgba = readonly [number, number, number, number];
3
+
4
+ /** Los mínimos de WCAG 2.1: texto (SC 1.4.3) y lo no textual con significado (SC 1.4.11). */
5
+ export declare const MINIMUM: Readonly<{ text: 4.5; 'non-text': 3 }>;
6
+
7
+ /** Luminancia relativa (WCAG 2.1, definición literal). */
8
+ export declare function luminance(color: Rgba): number;
9
+
10
+ /** Ratio de contraste entre dos colores opacos. */
11
+ export declare function contrastRatio(a: Rgba, b: Rgba): number;
12
+
13
+ /** `arriba` compuesto sobre un fondo OPACO, redondeado a 8 bits como lo pinta el navegador. */
14
+ export declare function over(arriba: Rgba, abajo: Rgba): Rgba;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Contraste WCAG 2.1 entre colores de 8 bits. Es la ÚNICA definición del repo: la usan el
3
+ * build de los tokens (su contrato, `build/contrast.ts`), los guardianes de `@netzi/ui` y el
4
+ * validador de marca que se publica (`@netzi/tokens/brand`). JavaScript plano y sin
5
+ * dependencias para que se publique tal cual; los tipos van en `contrast.d.ts`.
6
+ */
7
+
8
+ /** Los mínimos de WCAG 2.1: texto (SC 1.4.3) y lo no textual con significado (SC 1.4.11). */
9
+ export const MINIMUM = Object.freeze({ text: 4.5, 'non-text': 3 });
10
+
11
+ /** Luminancia relativa (WCAG 2.1, definición literal). */
12
+ export function luminance([r, g, b]) {
13
+ const lineal = (v) => {
14
+ const c = v / 255;
15
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
16
+ };
17
+ return 0.2126 * lineal(r) + 0.7152 * lineal(g) + 0.0722 * lineal(b);
18
+ }
19
+
20
+ /** Ratio de contraste entre dos colores opacos. */
21
+ export function contrastRatio(a, b) {
22
+ const [alta, baja] = [luminance(a), luminance(b)].sort((x, y) => y - x);
23
+ return (alta + 0.05) / (baja + 0.05);
24
+ }
25
+
26
+ /**
27
+ * `arriba` compuesto sobre un fondo OPACO, redondeado a 8 bits como lo pinta el navegador.
28
+ * Es también la mezcla `color-mix(in srgb, arriba P%, abajo)` con P = el alfa de `arriba`.
29
+ */
30
+ export function over(arriba, abajo) {
31
+ const a = arriba[3];
32
+ const canal = (i) => Math.round(arriba[i] * a + abajo[i] * (1 - a));
33
+ return [canal(0), canal(1), canal(2), 1];
34
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@netzi/tokens",
3
+ "version": "0.1.0-beta.10",
4
+ "description": "Design tokens de Netzi (CSS custom properties): marca, tema claro/oscuro, tipografía, radios, sombras, y un validador de marca propia con contraste. Agnóstico de framework.",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "homepage": "https://ui.netzi.tech",
7
+ "bugs": {
8
+ "email": "netzi.tech@gmail.com"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": [
12
+ "**/*.css"
13
+ ],
14
+ "files": [
15
+ "styles",
16
+ "lib",
17
+ "public-tokens.json",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "LICENSE.md"
21
+ ],
22
+ "exports": {
23
+ ".": "./styles/index.css",
24
+ "./tokens.css": "./styles/tokens.css",
25
+ "./base.css": "./styles/base.css",
26
+ "./fonts.css": "./styles/fonts.css",
27
+ "./index.css": "./styles/index.css",
28
+ "./public-tokens.json": "./public-tokens.json",
29
+ "./brand": {
30
+ "types": "./lib/brand.d.ts",
31
+ "default": "./lib/brand.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "keywords": [
39
+ "netzi",
40
+ "design-tokens",
41
+ "css",
42
+ "theme"
43
+ ],
44
+ "devDependencies": {
45
+ "prettier": "^3.4.2",
46
+ "style-dictionary": "~5.5.5"
47
+ },
48
+ "scripts": {
49
+ "build": "node build/build.ts --check",
50
+ "generate": "node build/build.ts",
51
+ "lint": "stylelint \"styles/**/*.css\"",
52
+ "test": "vitest run",
53
+ "test:cov": "vitest run --coverage",
54
+ "typecheck": "tsc -p tsconfig.json"
55
+ }
56
+ }