@ksmv/ui-checks 0.22.0 → 0.22.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1 - 2026-10-17
4
+
5
+ - **Contratos de tema 3.4.0 e de contraste 12.0.0** empacotados (#394). O de tema ganha o derivado opcional `--ui-surface-inverse-end`, a ponta do degradê da superfície inversa; o de contraste ganha 20 pares: o texto apagado sobre a superfície inversa (`inverse-muted-text`) e cada par de um derivado `-on-inverse` sobre a superfície medido também sobre a ponta (contexto `-end`). Nenhum par sai. Um tema que não declara nenhum dos dois tokens passa como antes.
6
+ - **A regra `tokens/contrast` mede um par na ponta de um degradê ao longo dele:** um token `<início>-end`, quando o início também é um token, é a ponta de um degradê, e o par é medido em 17 pontos, em sRGB e em Oklab (os espaços em que o navegador interpola um degradê que não declara espaço), com o alfa pré-multiplicado, ficando com o pior. Um texto que passa nas duas pontas pode reprovar no meio.
7
+ - **Quem usa `computedEvidence` precisa capturar a evidência de novo:** os dois contratos mudaram.
8
+
3
9
  ## 0.22.0 - 2026-10-17
4
10
 
5
11
  - **Contratos de tema 3.3.0 e de contraste 11.0.0** empacotados. O de tema ganha 89 derivados opcionais e 5 tokens de forma; o de contraste ganha 104 pares. Nenhum par sai. Os pares do pressionado contra as superfícies e os da linha com tom são obrigações novas, e um tema com o primário claro demais, ou com um fundo de tom escuro, reprova. Atualize o `@ksmv/ui-react` junto (ver o CHANGELOG dele).
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ksmv/ui-checks
2
2
 
3
- Verificações de qualidade para o contrato semântico `--ui-*` de `@ksmv/ui-react`: completude dos temas, contraste dos pares do contrato (ações, estados, campos, controles e, desde o contrato de contraste `3.0.0`, o texto base sobre as superfícies e, desde o `4.0.0`, a cor de link e, desde o `5.0.0`, a borda das ações e, desde o `6.0.0`, as variantes de ação da 0.17.0 e o botão de texto e, desde o `7.0.0`, os botões da casa da 0.18.1 e o hover por tom do botão de texto e, desde o `7.1.0`, o texto de hover dos botões suaves e do contorno de aviso e, desde o `9.0.0`, o texto, o limite de controle, o link, o botão de texto e o fantasma sobre a superfície inversa, a paleta categórica, a descrição do diálogo, o giro contra a superfície, o aviso e o rótulo de seção, o item de menu e o cartão de rádio, o cabeçalho da tabela e o grupo de alternância, e o selo cheio, a bolinha sobre a superfície inversa e a barra e, desde o `10.0.0`, o fundo do botão de ícone contornado, a cor da ajuda e a caixa de seleção e, desde o `11.0.0`, o botão pressionado contra as superfícies, a linha da lista com tom, o selo com marca à frente, o cabeçalho de página e a métrica, os passos e a navegação lateral, a conversa e a mídia, a escolha de horário e de dia, a grade de calendário e o quadro), prefixo dos tokens, cores literais fora dos arquivos de tema e evidência calculada vinculada às fontes.
3
+ Verificações de qualidade para o contrato semântico `--ui-*` de `@ksmv/ui-react`: completude dos temas, contraste dos pares do contrato (ações, estados, campos, controles e, desde o contrato de contraste `3.0.0`, o texto base sobre as superfícies e, desde o `4.0.0`, a cor de link e, desde o `5.0.0`, a borda das ações e, desde o `6.0.0`, as variantes de ação da 0.17.0 e o botão de texto e, desde o `7.0.0`, os botões da casa da 0.18.1 e o hover por tom do botão de texto e, desde o `7.1.0`, o texto de hover dos botões suaves e do contorno de aviso e, desde o `9.0.0`, o texto, o limite de controle, o link, o botão de texto e o fantasma sobre a superfície inversa, a paleta categórica, a descrição do diálogo, o giro contra a superfície, o aviso e o rótulo de seção, o item de menu e o cartão de rádio, o cabeçalho da tabela e o grupo de alternância, e o selo cheio, a bolinha sobre a superfície inversa e a barra e, desde o `10.0.0`, o fundo do botão de ícone contornado, a cor da ajuda e a caixa de seleção e, desde o `11.0.0`, o botão pressionado contra as superfícies, a linha da lista com tom, o selo com marca à frente, o cabeçalho de página e a métrica, os passos e a navegação lateral, a conversa e a mídia, a escolha de horário e de dia, a grade de calendário e o quadro e, desde o `12.0.0`, o texto apagado sobre a superfície inversa e cada par da superfície inversa medido também sobre a ponta do degradê), prefixo dos tokens, cores literais fora dos arquivos de tema e evidência calculada vinculada às fontes.
4
4
 
5
5
  A regra `css/no-literal-color` recusa cor literal fora dos temas configurados. Desde a 0.22.0, ela aceita uma exceção: o fallback de `var(--ui-x, <cor>)`, quando a cor é, texto por texto, o padrão literal que o contrato de tema dá à própria `--ui-x` (como `var(--ui-media-overlay-bg, rgb(0 0 0 / 70%))`). Quem escreve esse fallback implementa o contrato. Outra cor, ainda que equivalente, uma cor fora do `var()` ou o fallback de um token sem padrão literal continuam reprovados.
6
6
 
package/dist/color.d.ts CHANGED
@@ -19,4 +19,5 @@ export declare function referencedTokens(value: string): string[];
19
19
  export declare function unresolvedTokenReferences(value: string, tokens: ReadonlyMap<string, string>, stack?: ReadonlySet<string>): string[];
20
20
  export declare function resolveColor(value: string | undefined, tokens: ReadonlyMap<string, string>, stack?: ReadonlySet<string>): ColorResolution;
21
21
  export declare function composite(foreground: RgbColor, background: RgbColor): RgbColor;
22
+ export declare function mix(start: RgbColor, end: RgbColor, at: number): RgbColor[];
22
23
  export declare function contrastRatio(foreground: RgbColor, background: RgbColor): number | undefined;
package/dist/color.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { converter, parse } from "culori";
2
2
  import valueParser from "postcss-value-parser";
3
3
  const toRgb = converter("rgb");
4
+ const toOklab = converter("oklab");
4
5
  const unsupportedFunctions = new Set(["color-mix", "light-dark"]);
5
6
  const literalColorFunctions = new Set([
6
7
  "color",
@@ -195,6 +196,21 @@ export function composite(foreground, background) {
195
196
  r: foreground.r * alpha + background.r * (1 - alpha),
196
197
  };
197
198
  }
199
+ // A point of a gradient between two colors. The browser interpolates legacy
200
+ // colors (hex, rgb(), hsl()) in sRGB and the others, as oklch(), in Oklab
201
+ // (CSS Color 4, §12.1); the space is chosen by the syntax, so the check takes
202
+ // both points. Both interpolate with premultiplied alpha (§12.3): a channel
203
+ // is weighted by its color's alpha, so a transparent end lends no hue.
204
+ export function mix(start, end, at) {
205
+ const [startAlpha, endAlpha] = [start.alpha ?? 1, end.alpha ?? 1];
206
+ const alpha = startAlpha + (endAlpha - startAlpha) * at;
207
+ const between = (from, to) => alpha === 0 ? 0 : (from * startAlpha + (to * endAlpha - from * startAlpha) * at) / alpha;
208
+ const srgb = { alpha, b: between(start.b, end.b), g: between(start.g, end.g), mode: "rgb", r: between(start.r, end.r) };
209
+ const [from, to] = [toOklab(start), toOklab(end)];
210
+ const clamp = (value) => Math.min(1, Math.max(0, value));
211
+ const oklab = toRgb({ a: between(from.a, to.a), alpha, b: between(from.b, to.b), l: between(from.l, to.l), mode: "oklab" });
212
+ return [srgb, { alpha, b: clamp(oklab.b), g: clamp(oklab.g), mode: "rgb", r: clamp(oklab.r) }];
213
+ }
198
214
  function luminance(color) {
199
215
  const channel = (value) => value <= 0.04045
200
216
  ? value / 12.92
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "11.0.0",
2
+ "contractVersion": "12.0.0",
3
3
  "schemaVersion": 2,
4
4
  "pairs": [
5
5
  { "foreground": "--ui-action-primary-text", "background": "--ui-action-primary-bg", "minimum": 4.5, "context": "normal-text" },
@@ -317,6 +317,26 @@
317
317
  { "foreground": "--ui-board-card-border-hover", "background": "--ui-surface-subtle", "minimum": 3, "context": "board-card-border-hover" },
318
318
  { "foreground": "--ui-board-drop-border", "background": "--ui-surface-subtle", "minimum": 3, "context": "board-drop-border" },
319
319
  { "foreground": "--ui-board-drop-border", "background": "--ui-board-drop-bg", "over": "--ui-surface-subtle", "minimum": 3, "context": "board-drop-line" },
320
- { "foreground": "--ui-border-control", "background": "--ui-surface-subtle", "minimum": 3, "context": "board-placeholder-boundary" }
320
+ { "foreground": "--ui-border-control", "background": "--ui-surface-subtle", "minimum": 3, "context": "board-placeholder-boundary" },
321
+ { "foreground": "--ui-text-muted-on-inverse", "background": "--ui-surface-inverse", "minimum": 4.5, "context": "inverse-muted-text" },
322
+ { "foreground": "--ui-text-primary-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-body-text-end" },
323
+ { "foreground": "--ui-text-secondary-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-secondary-text-end" },
324
+ { "foreground": "--ui-border-control-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "inverse-control-boundary-end" },
325
+ { "foreground": "--ui-link-text-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-link-text-end" },
326
+ { "foreground": "--ui-text-button-text-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-text-button-text-end" },
327
+ { "foreground": "--ui-action-ghost-text-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-ghost-text-end" },
328
+ { "foreground": "--ui-action-ghost-text-hover-on-inverse", "background": "--ui-action-ghost-bg-hover-on-inverse", "over": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-ghost-text-hover-end" },
329
+ { "foreground": "--ui-action-ghost-text-hover-on-inverse", "background": "--ui-action-ghost-bg-active-on-inverse", "over": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-ghost-text-active-end" },
330
+ { "foreground": "--ui-icon-button-ghost-text-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "inverse-icon-button-ghost-icon-end" },
331
+ { "foreground": "--ui-icon-button-ghost-text-on-inverse", "background": "--ui-action-ghost-bg-hover-on-inverse", "over": "--ui-surface-inverse-end", "minimum": 3, "context": "inverse-icon-button-ghost-icon-hover-end" },
332
+ { "foreground": "--ui-icon-button-ghost-text-on-inverse", "background": "--ui-action-ghost-bg-active-on-inverse", "over": "--ui-surface-inverse-end", "minimum": 3, "context": "inverse-icon-button-ghost-icon-active-end" },
333
+ { "foreground": "--ui-status-success-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "status-indicator-success-on-inverse-end" },
334
+ { "foreground": "--ui-status-warning-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "status-indicator-warning-on-inverse-end" },
335
+ { "foreground": "--ui-status-danger-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "status-indicator-danger-on-inverse-end" },
336
+ { "foreground": "--ui-status-info-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "status-indicator-info-on-inverse-end" },
337
+ { "foreground": "--ui-button-pressed-bg-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "button-pressed-boundary-on-inverse-end" },
338
+ { "foreground": "--ui-button-pressed-bg-hover-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 3, "context": "button-pressed-boundary-hover-on-inverse-end" },
339
+ { "foreground": "--ui-side-nav-count-text-on-inverse", "background": "--ui-side-nav-count-bg-on-inverse", "over": "--ui-surface-inverse-end", "minimum": 4.5, "context": "side-nav-count-text-on-inverse-end" },
340
+ { "foreground": "--ui-text-muted-on-inverse", "background": "--ui-surface-inverse-end", "minimum": 4.5, "context": "inverse-muted-text-end" }
321
341
  ]
322
342
  }
@@ -1,7 +1,26 @@
1
1
  import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isJsxSpreadAttribute, isStringLiteralLikeNode, } from "typescript/unstable/ast";
2
- import { composite, contrastRatio, isColorBearingProperty, resolveColor, } from "../color.js";
2
+ import { composite, contrastRatio, isColorBearingProperty, mix, resolveColor, } from "../color.js";
3
3
  import { parseContrastManifest, } from "../contracts.js";
4
4
  import { createThemeCollector } from "./theme.js";
5
+ // The end of a gradient (#394): a token named <start>-end, when the start is
6
+ // a token too, paints a gradient from the start to itself. A pair on it is
7
+ // measured along the whole gradient, and not only at the end: between two
8
+ // colors that pass, the mix can fall below the minimum, as when the text's
9
+ // luminance lies between the two ends. Each point is taken in sRGB and in
10
+ // Oklab, the two spaces a browser may interpolate in.
11
+ const gradientSteps = 16;
12
+ function resolveAlong(token, tokens) {
13
+ const end = resolveColor(tokens.get(token), tokens);
14
+ const startName = token.endsWith("-end") ? token.slice(0, -"-end".length) : undefined;
15
+ if (!startName || !tokens.has(startName))
16
+ return [end];
17
+ const start = resolveColor(tokens.get(startName), tokens);
18
+ if (start.status !== "resolved" || end.status !== "resolved")
19
+ return [{ status: "uncovered" }];
20
+ return Array.from({ length: gradientSteps + 1 }, (_, step) => mix(start.color, end.color, step / gradientSteps))
21
+ .flat()
22
+ .map((color) => ({ color, status: "resolved" }));
23
+ }
5
24
  export async function loadContrastManifest(url) {
6
25
  const { readFile } = await import("node:fs/promises");
7
26
  const packaged = url ?? new URL("../contrast-contract.json", import.meta.url);
@@ -144,17 +163,25 @@ export function createTokenContrastRule({ computed = {}, defaults = {}, derived
144
163
  tokens.set(token, value);
145
164
  }
146
165
  for (const pair of manifest.pairs) {
147
- let background = resolveColor(tokens.get(pair.background), tokens);
148
166
  const foreground = resolveColor(tokens.get(pair.foreground), tokens);
149
- // A translucent background is measured as painted over the surface
150
- // the pair names (schema 2); without one it stays uncovered below.
151
- if (pair.over && background.status === "resolved" && (background.color.alpha ?? 1) < 1) {
152
- const over = resolveColor(tokens.get(pair.over), tokens);
153
- background = over.status === "resolved" && (over.color.alpha ?? 1) === 1
154
- ? { color: composite(background.color, over.color), status: "resolved" }
155
- : { status: "uncovered" };
167
+ // Each point of the background, a gradient's included, and of the
168
+ // surface under it; the pair is as good as its worst point.
169
+ const backgrounds = [];
170
+ for (const painted of resolveAlong(pair.background, tokens)) {
171
+ // A translucent background is measured as painted over the surface
172
+ // the pair names (schema 2); without one it stays uncovered below.
173
+ if (pair.over && painted.status === "resolved" && (painted.color.alpha ?? 1) < 1) {
174
+ for (const over of resolveAlong(pair.over, tokens)) {
175
+ backgrounds.push(over.status === "resolved" && (over.color.alpha ?? 1) === 1
176
+ ? { color: composite(painted.color, over.color), status: "resolved" }
177
+ : { status: "uncovered" });
178
+ }
179
+ }
180
+ else {
181
+ backgrounds.push(painted);
182
+ }
156
183
  }
157
- if (background.status === "uncovered" ||
184
+ if (backgrounds.some((background) => background.status === "uncovered") ||
158
185
  foreground.status === "uncovered") {
159
186
  result.uncovered.push({
160
187
  ...location,
@@ -165,7 +192,8 @@ export function createTokenContrastRule({ computed = {}, defaults = {}, derived
165
192
  });
166
193
  continue;
167
194
  }
168
- const ratio = contrastRatio(foreground.color, background.color);
195
+ const ratios = backgrounds.map((background) => background.status === "resolved" ? contrastRatio(foreground.color, background.color) : undefined);
196
+ const ratio = ratios.includes(undefined) ? undefined : Math.min(...ratios);
169
197
  if (ratio === undefined) {
170
198
  result.uncovered.push({
171
199
  ...location,
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "3.3.0",
2
+ "contractVersion": "3.4.0",
3
3
  "schemaVersion": 2,
4
4
  "prefix": "--ui-",
5
5
  "required": [
@@ -320,7 +320,8 @@
320
320
  "--ui-board-count-text": { "kind": "token", "token": "--ui-text-primary" },
321
321
  "--ui-board-card-border-hover": { "kind": "token", "token": "--ui-action-primary-bg" },
322
322
  "--ui-board-drop-bg": { "kind": "token", "token": "--ui-action-soft-accent-bg" },
323
- "--ui-board-drop-border": { "kind": "token", "token": "--ui-action-primary-bg" }
323
+ "--ui-board-drop-border": { "kind": "token", "token": "--ui-action-primary-bg" },
324
+ "--ui-surface-inverse-end": { "kind": "token", "token": "--ui-surface-inverse" }
324
325
  },
325
326
  "shape": [
326
327
  "--ui-font-body",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksmv/ui-checks",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Static and computed quality checks for semantic UI token contracts.",
5
5
  "keywords": ["accessibility", "design-tokens", "quality", "static-analysis"],
6
6
  "license": "Apache-2.0",