@ksmv/ui-checks 0.3.2 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-10-03
4
+
5
+ - **Contratos de tema 2.2.0 e de contraste 2.0.0** empacotados: dois derivados e dois pares do controle marcado (ver o CHANGELOG do `@ksmv/ui-react`). Um par novo pode reprovar um tema que hoje passa.
6
+ - **`tokens/contrast` resolve derivados não declarados** pela cadeia do contrato até um token declarado. Na 0.3.3 e anteriores, um par com um derivado que o tema não declarava ficava como cobertura pendente quando não havia evidência calculada.
7
+ - **A evidência pede a origem de um derivado usado num par de contraste.** Na 0.3.3 e anteriores, toda ponta de par era exigida com valor, e um derivado não declarado, que o navegador informa vazio, fazia a evidência ser recusada.
8
+ - **A identidade dos contratos muda**, então evidência capturada antes da atualização é recusada com código 2: quem usa `computedEvidence` precisa capturar de novo.
9
+
10
+ ## 0.3.3 - 2026-10-02
11
+
12
+ - **O erro de inicialização das regras diz a causa.** Na 0.3.2 e anteriores, evidência recusada ou contrato não resolvido saíam com código 2 e só com `Unable to initialize configured rules.`. O erro operacional agora diz qual foi o motivo e, quando há, o remédio: arquivo de evidência ausente, ilegível ou fora do formato; identidade do contrato diferente da instalada, com as duas versões; hash das fontes do tema diferente; seletores ou tema diferentes dos configurados; `capturedAt` inválido ou no futuro; tokens sem valor ou com `var(...)` restante, com os nomes dos tokens. A mensagem não repete valores do produto, e a saída continua sendo código 2.
13
+ - Quem compara o texto exato `Invalid computed theme evidence.` lançado por `loadComputedEvidence` precisa ajustar: as mensagens novas começam com esse texto e seguem com a causa.
14
+ - **Contrato de tema 2.1.0** empacotado, com 3 derivados opcionais novos: `--ui-button-font-weight-sm`, `--ui-button-font-weight-md` e `--ui-button-font-weight-lg`, que herdam de `--ui-button-font-weight`. Nenhuma declaração nova é exigida do tema.
15
+ - **A identidade do contrato muda**, então evidência capturada antes da atualização é recusada com código 2: quem usa `computedEvidence` precisa capturar de novo.
16
+ - `tokens/complete` valida os três como peso (inteiro de 1 a 1000, `normal` ou `bold`) e passa a seguir a cadeia de derivados até um token declarado. Na 0.3.2 e anteriores só o primeiro nível era conferido, e um derivado de outro derivado ficava sem fallback quando o tema não declarava o intermediário.
17
+
3
18
  ## 0.3.2 - 2026-10-01
4
19
 
5
20
  - **O hash das fontes do tema deixa de depender do fim de linha do checkout.** Num repositório sem regra de fim de linha, o mesmo commit sai com CRLF no Windows e com LF no Linux. Na 0.3.1 e anteriores o hash cobria os bytes do arquivo, então a evidência capturada num sistema era recusada no outro, com código 2: verde na máquina de quem capturou e vermelho no CI. `CRLF` passa a contar como `LF`; qualquer outra diferença continua mudando o hash.
package/README.md CHANGED
@@ -80,13 +80,22 @@ Seletores disjuntos, como `[data-theme="light"]` e `[data-theme="dark"]`, são v
80
80
 
81
81
  A evidência não é uma exceção manual. O arquivo é recusado, com código `2`, quando:
82
82
 
83
- - `capturedAt` está no futuro;
84
- - os seletores diferem dos configurados;
83
+ - o arquivo não existe, não pode ser lido, não é JSON válido ou não tem a estrutura da receita abaixo;
84
+ - `capturedAt` não é um horário UTC ou está no futuro;
85
+ - os seletores diferem dos configurados, ou o tema nomeado em `computedEvidence.theme` não está no arquivo ou foi capturado para outro seletor;
85
86
  - o hash das fontes diverge, ou seja, o tema mudou depois da captura (o fim de linha não conta: o mesmo arquivo em CRLF e em LF tem o mesmo hash);
86
87
  - a identidade de um dos contratos diverge da instalada, ou seja, o pacote React mudou depois da captura;
87
88
  - falta algum token exigido, ou algum valor ainda contém `var(...)`.
88
89
 
89
- Tokens opcionais, os derivados e os de marca, podem ficar vazios na captura: é assim que o navegador informa um token que o tema não declarou. Um derivado vazio assume o valor do token de origem, que é o padrão documentado no contrato.
90
+ O erro operacional diz qual desses motivos ocorreu e, quando há, o remédio. Por exemplo, depois de alterar um arquivo de tema sem capturar de novo:
91
+
92
+ ```text
93
+ config Invalid computed theme evidence in artifacts/theme-evidence.json: the theme source hash differs, so a theme file changed after the capture. Capture the evidence again.
94
+ ```
95
+
96
+ Depois de atualizar o pacote React, a mensagem traz a versão do contrato da captura e a do contrato instalado; quando faltam tokens ou resta `var(...)`, traz os nomes dos tokens, em ordem, até dez, e a contagem dos demais. A mensagem não repete valores do produto: aparecem só o caminho da evidência e o nome do tema como estão na configuração, nomes de tokens e versões de contrato. Falha ao resolver os contratos também é relatada com a própria causa (`Unable to resolve the configured contract source.`, `Unsupported theme contract.` ou `Unsupported contrast manifest.`).
97
+
98
+ Tokens opcionais, os derivados e os de marca, podem ficar vazios na captura: é assim que o navegador informa um token que o tema não declarou. Um derivado vazio assume o valor do token de origem, que é o padrão documentado no contrato. Quando a origem também é um derivado, como no peso do botão por tamanho (`--ui-button-font-weight-sm`, `-md` e `-lg` derivam de `--ui-button-font-weight`), a cadeia é seguida até um token declarado. Um derivado que é ponta de um par de contraste, como `--ui-control-checked-bg`, também pode ficar vazio; a captura precisa então do token em que a cadeia dele termina. Sem evidência, `tokens/contrast` segue a mesma cadeia: um derivado que o tema não declara é avaliado pelo valor da origem.
90
99
 
91
100
  Os tokens de densidade têm o valor conferido por `tokens/complete`: comprimento maior que zero em `px`, `rem` ou `em` para fonte e altura; também `0` para respiro e espaçamento; número sem unidade maior que zero para a entrelinha; em todos esses, também `calc()`, `clamp()`, `min()` ou `max()`; inteiro de 1 a 1000, `normal` ou `bold` para o peso. Com evidência, vale o valor capturado no navegador. Sem evidência, um valor que chega por `var()` é conferido pelo que ele alcança: o token declarado no mesmo tema ou, se não houver, o fallback do próprio `var()`; uma referência que não chega a valor nenhum fica como cobertura pendente. Valor inválido é cobertura obrigatória e não entra em baseline.
92
101
 
@@ -32,6 +32,13 @@ export interface ContractSet {
32
32
  source: ContractSource;
33
33
  theme: ThemeContract;
34
34
  }
35
+ /**
36
+ * A failure whose message this package wrote, so a report may repeat it. The
37
+ * message of any other error is never reported: it could carry product content.
38
+ */
39
+ export declare class CheckSetupError extends Error {
40
+ }
41
+ export declare const semverPattern: RegExp;
35
42
  export declare function parseThemeContract(content: string): ThemeContract;
36
43
  export declare function parseContrastManifest(content: string): ContrastManifest;
37
44
  export declare function resolveContractSet({ root, source, }: {
package/dist/contracts.js CHANGED
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
4
  import { resolve } from "node:path";
5
5
  export const tokenNamePattern = /^--ui-[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
- const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
6
+ /**
7
+ * A failure whose message this package wrote, so a report may repeat it. The
8
+ * message of any other error is never reported: it could carry product content.
9
+ */
10
+ export class CheckSetupError extends Error {
11
+ }
12
+ export const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
7
13
  function exactKeys(value, keys) {
8
14
  return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
9
15
  }
@@ -19,7 +25,7 @@ export function parseThemeContract(content) {
19
25
  value = JSON.parse(content);
20
26
  }
21
27
  catch {
22
- throw new Error("Unsupported theme contract.");
28
+ throw new CheckSetupError("Unsupported theme contract.");
23
29
  }
24
30
  if (!isRecord(value) ||
25
31
  !exactKeys(value, [
@@ -42,7 +48,7 @@ export function parseThemeContract(content) {
42
48
  Object.entries(value.derived).some(([token, fallback]) => !tokenNamePattern.test(token) ||
43
49
  typeof fallback !== "string" ||
44
50
  !tokenNamePattern.test(fallback))) {
45
- throw new Error("Unsupported theme contract.");
51
+ throw new CheckSetupError("Unsupported theme contract.");
46
52
  }
47
53
  return value;
48
54
  }
@@ -52,7 +58,7 @@ export function parseContrastManifest(content) {
52
58
  value = JSON.parse(content);
53
59
  }
54
60
  catch {
55
- throw new Error("Unsupported contrast manifest.");
61
+ throw new CheckSetupError("Unsupported contrast manifest.");
56
62
  }
57
63
  if (!isRecord(value) ||
58
64
  !exactKeys(value, ["contractVersion", "pairs", "schemaVersion"]) ||
@@ -60,7 +66,7 @@ export function parseContrastManifest(content) {
60
66
  typeof value.contractVersion !== "string" ||
61
67
  !semverPattern.test(value.contractVersion) ||
62
68
  !Array.isArray(value.pairs)) {
63
- throw new Error("Unsupported contrast manifest.");
69
+ throw new CheckSetupError("Unsupported contrast manifest.");
64
70
  }
65
71
  const identities = [];
66
72
  for (const pair of value.pairs) {
@@ -75,12 +81,12 @@ export function parseContrastManifest(content) {
75
81
  typeof pair.minimum !== "number" ||
76
82
  !Number.isFinite(pair.minimum) ||
77
83
  pair.minimum <= 0) {
78
- throw new Error("Unsupported contrast manifest.");
84
+ throw new CheckSetupError("Unsupported contrast manifest.");
79
85
  }
80
86
  identities.push(`${pair.context}\u0000${pair.foreground}\u0000${pair.background}`);
81
87
  }
82
88
  if (new Set(identities).size !== identities.length) {
83
- throw new Error("Unsupported contrast manifest.");
89
+ throw new CheckSetupError("Unsupported contrast manifest.");
84
90
  }
85
91
  return value;
86
92
  }
@@ -99,7 +105,7 @@ async function reactContractPaths(root) {
99
105
  };
100
106
  }
101
107
  catch {
102
- throw new Error("Unable to resolve the configured contract source.");
108
+ throw new CheckSetupError("Unable to resolve the configured contract source.");
103
109
  }
104
110
  }
105
111
  export async function resolveContractSet({ root, source, }) {
@@ -127,12 +133,9 @@ export async function resolveContractSet({ root, source, }) {
127
133
  };
128
134
  }
129
135
  catch (error) {
130
- if (error instanceof Error &&
131
- (error.message === "Unsupported theme contract." ||
132
- error.message === "Unsupported contrast manifest." ||
133
- error.message === "Unable to resolve the configured contract source.")) {
136
+ if (error instanceof CheckSetupError) {
134
137
  throw error;
135
138
  }
136
- throw new Error("Unable to resolve the configured contract source.");
139
+ throw new CheckSetupError("Unable to resolve the configured contract source.");
137
140
  }
138
141
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.0.0",
2
+ "contractVersion": "2.0.0",
3
3
  "schemaVersion": 1,
4
4
  "pairs": [
5
5
  { "foreground": "--ui-action-primary-text", "background": "--ui-action-primary-bg", "minimum": 4.5, "context": "normal-text" },
@@ -33,6 +33,8 @@
33
33
  { "foreground": "--ui-text-inverse", "background": "--ui-surface-inverse", "minimum": 4.5, "context": "inverse-text" },
34
34
  { "foreground": "--ui-border-control", "background": "--ui-surface", "minimum": 3, "context": "control-boundary" },
35
35
  { "foreground": "--ui-focus-ring", "background": "--ui-surface", "minimum": 3, "context": "focus-indicator" },
36
- { "foreground": "--ui-focus-ring", "background": "--ui-focus-ring-offset", "minimum": 3, "context": "focus-indicator" }
36
+ { "foreground": "--ui-focus-ring", "background": "--ui-focus-ring-offset", "minimum": 3, "context": "focus-indicator" },
37
+ { "foreground": "--ui-control-checked-bg", "background": "--ui-surface", "minimum": 3, "context": "checked-control-boundary" },
38
+ { "foreground": "--ui-control-checked-fg", "background": "--ui-control-checked-bg", "minimum": 3, "context": "checked-indicator" }
37
39
  ]
38
40
  }
package/dist/density.js CHANGED
@@ -28,6 +28,9 @@ export const densityTokenKinds = Object.freeze({
28
28
  "--ui-field-height": "control-height",
29
29
  "--ui-dialog-title-font-size": "font-size",
30
30
  "--ui-button-font-weight": "font-weight",
31
+ "--ui-button-font-weight-sm": "font-weight",
32
+ "--ui-button-font-weight-md": "font-weight",
33
+ "--ui-button-font-weight-lg": "font-weight",
31
34
  });
32
35
  const lengthPattern = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/iu;
33
36
  const math = /^(?:calc|clamp|min|max)\(.+\)$/iu;
package/dist/evidence.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { isAbsolute, relative, resolve } from "node:path";
4
- import { tokenNamePattern, } from "./contracts.js";
4
+ import { CheckSetupError, semverPattern, tokenNamePattern, } from "./contracts.js";
5
+ const hashPattern = /^sha256:[a-f0-9]{64}$/;
6
+ const tokenListLimit = 10;
5
7
  function normalizedFiles(root, files) {
6
8
  return [...new Set(files.map((file) => file.replaceAll("\\", "/")))]
7
9
  .sort()
@@ -11,7 +13,7 @@ function normalizedFiles(root, files) {
11
13
  if (isAbsolute(file) ||
12
14
  fromRoot === ".." ||
13
15
  fromRoot.startsWith("../")) {
14
- throw new Error("Invalid computed theme evidence.");
16
+ throw new CheckSetupError("Theme files must be relative paths inside the configured root.");
15
17
  }
16
18
  return { absolute, path: fromRoot };
17
19
  });
@@ -43,65 +45,75 @@ function exactKeys(value, keys) {
43
45
  function isRecord(value) {
44
46
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
45
47
  }
46
- function sameIdentity(actual, expected) {
47
- return isRecord(actual) &&
48
- exactKeys(actual, ["contractVersion", "sha256"]) &&
49
- actual.contractVersion === expected.contractVersion &&
50
- actual.sha256 === expected.sha256;
48
+ function isIdentity(value) {
49
+ return isRecord(value) &&
50
+ exactKeys(value, ["contractVersion", "sha256"]) &&
51
+ typeof value.contractVersion === "string" &&
52
+ semverPattern.test(value.contractVersion) &&
53
+ typeof value.sha256 === "string" &&
54
+ hashPattern.test(value.sha256);
51
55
  }
52
56
  // Derived tokens are not required: a theme may leave them to their documented
53
- // default, the source token, and the browser then reports them as empty.
57
+ // default, the source token, and the browser then reports them as empty. A
58
+ // contrast endpoint that is such a token is required through its source.
54
59
  function requiredEvidenceTokens(contracts) {
55
60
  const derived = contracts.theme.derived;
61
+ const required = new Set(contracts.theme.required);
62
+ const origin = (token, seen = new Set()) => {
63
+ const source = derived[token];
64
+ if (required.has(token) || source === undefined || seen.has(token))
65
+ return token;
66
+ return origin(source, new Set([...seen, token]));
67
+ };
56
68
  return new Set([
57
- ...contracts.theme.required,
69
+ ...required,
58
70
  // A source that is itself derived is optional too; its chain is followed.
59
71
  ...Object.values(derived).filter((source) => !(source in derived)),
60
72
  ...contracts.contrast.pairs.flatMap(({ background, foreground }) => [
61
- background,
62
- foreground,
73
+ origin(background),
74
+ origin(foreground),
63
75
  ]),
64
76
  ]);
65
77
  }
66
- function validateEvidenceShape(value, contracts) {
67
- if (!isRecord(value) ||
68
- !exactKeys(value, [
78
+ // Structure only: whether the content agrees with the contracts, the
79
+ // configuration and the theme files is checked afterwards, cause by cause.
80
+ function hasEvidenceStructure(value) {
81
+ return isRecord(value) &&
82
+ exactKeys(value, [
69
83
  "capturedAt",
70
84
  "contracts",
71
85
  "schemaVersion",
72
86
  "selectors",
73
87
  "sourceHash",
74
88
  "themes",
75
- ]) ||
76
- value.schemaVersion !== 1 ||
77
- typeof value.capturedAt !== "string" ||
78
- !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value.capturedAt) ||
79
- typeof value.sourceHash !== "string" ||
80
- !/^sha256:[a-f0-9]{64}$/.test(value.sourceHash) ||
81
- !Array.isArray(value.selectors) ||
82
- value.selectors.some((selector) => typeof selector !== "string") ||
83
- !isRecord(value.contracts) ||
84
- !exactKeys(value.contracts, ["contrast", "theme"]) ||
85
- !sameIdentity(value.contracts.contrast, contracts.identities.contrast) ||
86
- !sameIdentity(value.contracts.theme, contracts.identities.theme) ||
87
- !isRecord(value.themes)) {
88
- return false;
89
- }
90
- // A token the evidence must carry is never optional, even when the contract
91
- // also lists it as derived or as a brand token.
92
- const required = requiredEvidenceTokens(contracts);
93
- const optional = new Set([...Object.keys(contracts.theme.derived), ...contracts.theme.brandComposition]
94
- .filter((token) => !required.has(token)));
95
- return Object.values(value.themes).every((theme) => isRecord(theme) &&
96
- exactKeys(theme, ["selector", "tokens"]) &&
97
- typeof theme.selector === "string" &&
98
- isRecord(theme.tokens) &&
99
- Object.entries(theme.tokens).every(([token, tokenValue]) => tokenNamePattern.test(token) &&
100
- typeof tokenValue === "string" &&
101
- // Empty means undeclared, which only an optional token may be: a derived
102
- // token left to its default, or a brand token the theme does not use.
103
- (tokenValue.trim().length > 0 || optional.has(token)) &&
104
- !/var\s*\(/i.test(tokenValue)));
89
+ ]) &&
90
+ value.schemaVersion === 1 &&
91
+ typeof value.capturedAt === "string" &&
92
+ typeof value.sourceHash === "string" &&
93
+ hashPattern.test(value.sourceHash) &&
94
+ Array.isArray(value.selectors) &&
95
+ value.selectors.every((selector) => typeof selector === "string") &&
96
+ isRecord(value.contracts) &&
97
+ exactKeys(value.contracts, ["contrast", "theme"]) &&
98
+ isIdentity(value.contracts.contrast) &&
99
+ isIdentity(value.contracts.theme) &&
100
+ isRecord(value.themes) &&
101
+ Object.values(value.themes).every((theme) => isRecord(theme) &&
102
+ exactKeys(theme, ["selector", "tokens"]) &&
103
+ typeof theme.selector === "string" &&
104
+ isRecord(theme.tokens) &&
105
+ Object.entries(theme.tokens).every(([token, tokenValue]) => tokenNamePattern.test(token) && typeof tokenValue === "string"));
106
+ }
107
+ function counted(tokens, noun) {
108
+ return `${tokens.length} ${noun}${tokens.length === 1 ? "" : "s"}`;
109
+ }
110
+ // Token names only, never their values, and a long list is cut short.
111
+ function named(tokens) {
112
+ const sorted = [...tokens].sort();
113
+ const shown = sorted.slice(0, tokenListLimit).join(", ");
114
+ return sorted.length > tokenListLimit
115
+ ? `${shown} and ${sorted.length - tokenListLimit} more`
116
+ : shown;
105
117
  }
106
118
  export async function loadComputedEvidence({ contracts, evidenceByPath, now = new Date(), root, themes, }) {
107
119
  try {
@@ -113,35 +125,100 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
113
125
  }
114
126
  const result = {};
115
127
  const requiredTokens = requiredEvidenceTokens(contracts);
128
+ // A token the evidence must carry is never optional, even when the contract
129
+ // also lists it as derived or as a brand token.
130
+ const optionalTokens = new Set([...Object.keys(contracts.theme.derived), ...contracts.theme.brandComposition]
131
+ .filter((token) => !requiredTokens.has(token)));
116
132
  for (const [path, group] of grouped) {
117
- const injected = evidenceByPath?.get(path);
118
- const evidence = injected ?? JSON.parse(await readFile(resolve(root, path), "utf8"));
119
- if (!validateEvidenceShape(evidence, contracts)) {
120
- throw new Error("shape");
133
+ const refused = (cause) => new CheckSetupError(`Invalid computed theme evidence in ${path}: ${cause}`);
134
+ let evidence = evidenceByPath?.get(path);
135
+ if (evidence === undefined) {
136
+ let content;
137
+ try {
138
+ content = await readFile(resolve(root, path), "utf8");
139
+ }
140
+ catch {
141
+ throw refused("the file is missing or cannot be read. Capture the evidence, or correct computedEvidence.file.");
142
+ }
143
+ try {
144
+ evidence = JSON.parse(content);
145
+ }
146
+ catch {
147
+ throw refused("the file is not valid JSON. Capture the evidence again.");
148
+ }
149
+ }
150
+ if (!hasEvidenceStructure(evidence)) {
151
+ throw refused("the file does not have the evidence structure. Capture the evidence again with the documented recipe.");
152
+ }
153
+ // The contracts come first: after a package upgrade the capture is stale
154
+ // in other ways too, and capturing again is what resolves them.
155
+ for (const contract of ["theme", "contrast"]) {
156
+ const captured = evidence.contracts[contract];
157
+ const installed = contracts.identities[contract];
158
+ if (captured.contractVersion !== installed.contractVersion) {
159
+ throw refused(`it was captured for ${contract} contract ${captured.contractVersion}, and the installed ${contract} contract is ${installed.contractVersion}. Bring the theme up to the installed contract and capture the evidence again.`);
160
+ }
161
+ if (captured.sha256 !== installed.sha256) {
162
+ throw refused(`it was captured for another build of ${contract} contract ${installed.contractVersion} than the installed one. Capture the evidence again.`);
163
+ }
121
164
  }
122
165
  const capturedAt = new Date(evidence.capturedAt);
123
- if (!Number.isFinite(capturedAt.valueOf()) || capturedAt > now) {
124
- throw new Error("time");
166
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(evidence.capturedAt) ||
167
+ !Number.isFinite(capturedAt.valueOf())) {
168
+ throw refused("capturedAt is not a UTC timestamp such as 2026-01-31T12:00:00.000Z. Capture the evidence again.");
169
+ }
170
+ if (capturedAt > now) {
171
+ throw refused("capturedAt is in the future. Correct the clock of the capturing machine and capture the evidence again.");
125
172
  }
126
173
  const selectors = [...new Set(group.map(({ selector }) => selector))].sort();
127
174
  if ([...new Set(evidence.selectors)].sort().join("\0") !== selectors.join("\0")) {
128
- throw new Error("selectors");
175
+ throw refused("its selectors differ from the selectors configured for the themes that use this file. Capture the evidence again.");
129
176
  }
130
177
  const files = [...new Set(group.flatMap(({ files }) => files))];
131
- if (evidence.sourceHash !== await computeThemeSourceHash(root, files)) {
132
- throw new Error("source");
178
+ let sourceHash;
179
+ try {
180
+ sourceHash = await computeThemeSourceHash(root, files);
181
+ }
182
+ catch (error) {
183
+ if (error instanceof CheckSetupError)
184
+ throw error;
185
+ throw new CheckSetupError(`Unable to read the theme files that use the computed evidence in ${path}.`);
133
186
  }
187
+ if (evidence.sourceHash !== sourceHash) {
188
+ throw refused("the theme source hash differs, so a theme file changed after the capture. Capture the evidence again.");
189
+ }
190
+ // A referenced theme has to carry every required token; any other theme
191
+ // in the file only has to be sound in what it does carry.
192
+ const checkValues = (subject, tokens, referenced) => {
193
+ const hasNoValue = (token) => tokens[token] === undefined ? referenced : tokens[token].trim().length === 0;
194
+ const missing = [...requiredTokens].filter(hasNoValue);
195
+ if (missing.length > 0) {
196
+ throw refused(`${subject} has no value for ${counted(missing, "required token")}: ${named(missing)}. Declare ${missing.length === 1 ? "it" : "them"} in the theme and capture the evidence again.`);
197
+ }
198
+ // Empty means undeclared, which only an optional token may be: a derived
199
+ // token left to its default, or a brand token the theme does not use.
200
+ const empty = Object.keys(tokens).filter((token) => hasNoValue(token) && !optionalTokens.has(token));
201
+ if (empty.length > 0) {
202
+ throw refused(`${subject} has an empty value for ${counted(empty, "token")} the contract does not make optional: ${named(empty)}. Capture the evidence again with the documented recipe.`);
203
+ }
204
+ const unresolved = Object.keys(tokens).filter((token) => /var\s*\(/i.test(tokens[token]));
205
+ if (unresolved.length > 0) {
206
+ throw refused(`${subject} still has var(...) in the value of ${counted(unresolved, "token")}: ${named(unresolved)}. Capture the values computed by a browser, where every var() is resolved.`);
207
+ }
208
+ };
134
209
  for (const theme of group) {
135
210
  const name = theme.computedEvidence.theme;
136
- const computedTheme = evidence.themes[name];
137
- if (!computedTheme || computedTheme.selector !== theme.selector) {
138
- throw new Error("theme");
211
+ const subject = `its theme ${JSON.stringify(name)}`;
212
+ const computedTheme = Object.hasOwn(evidence.themes, name)
213
+ ? evidence.themes[name]
214
+ : undefined;
215
+ if (!computedTheme) {
216
+ throw refused(`it has no theme named ${JSON.stringify(name)}, the name in computedEvidence.theme. Correct the name or capture the evidence again.`);
139
217
  }
140
- for (const token of requiredTokens) {
141
- if (computedTheme.tokens[token] === undefined) {
142
- throw new Error("tokens");
143
- }
218
+ if (computedTheme.selector !== theme.selector) {
219
+ throw refused(`${subject} was captured for another selector than the configured one. Capture the evidence again.`);
144
220
  }
221
+ checkValues(subject, computedTheme.tokens, true);
145
222
  const tokens = Object.fromEntries(Object.entries(computedTheme.tokens).filter(([, tokenValue]) => tokenValue.trim().length > 0));
146
223
  // A derived token left to its default takes its source's value,
147
224
  // following sources that are derived themselves. A chain that does not
@@ -152,18 +229,34 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
152
229
  return value;
153
230
  const source = contracts.theme.derived[token];
154
231
  if (source === undefined || seen.has(token))
155
- throw new Error("tokens");
232
+ return undefined;
156
233
  return derivedValue(source, new Set([...seen, token]));
157
234
  };
235
+ const underivable = [];
158
236
  for (const token of Object.keys(contracts.theme.derived)) {
159
- tokens[token] = derivedValue(token, new Set());
237
+ const value = derivedValue(token, new Set());
238
+ if (value === undefined)
239
+ underivable.push(token);
240
+ else
241
+ tokens[token] = value;
242
+ }
243
+ if (underivable.length > 0) {
244
+ throw refused(`${subject} has no value from which to derive ${counted(underivable, "token")}: ${named(underivable)}.`);
160
245
  }
161
246
  result[theme.name] = tokens;
162
247
  }
248
+ const referenced = new Set(group.map((theme) => theme.computedEvidence.theme));
249
+ for (const [name, theme] of Object.entries(evidence.themes)) {
250
+ if (!referenced.has(name)) {
251
+ checkValues("a theme the configuration does not reference", theme.tokens, false);
252
+ }
253
+ }
163
254
  }
164
255
  return result;
165
256
  }
166
- catch {
167
- throw new Error("Invalid computed theme evidence.");
257
+ catch (error) {
258
+ if (error instanceof CheckSetupError)
259
+ throw error;
260
+ throw new CheckSetupError("Invalid computed theme evidence.");
168
261
  }
169
262
  }
@@ -12,7 +12,12 @@ export async function createDefaultRules({ components, computed, contract, manif
12
12
  createNoLiteralColorRule({ themes }),
13
13
  createTokenPrefixRule(),
14
14
  createTokenCompleteRule({ computed, contract: resolvedContract, themes }),
15
- createTokenContrastRule({ computed, manifest: resolvedManifest, themes }),
15
+ createTokenContrastRule({
16
+ computed,
17
+ derived: resolvedContract.derived,
18
+ manifest: resolvedManifest,
19
+ themes,
20
+ }),
16
21
  ...(components
17
22
  ? [createComponentOverrideRule({ classHelpers: components.classHelpers, sources: components.sources })]
18
23
  : []),
@@ -75,9 +75,18 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
75
75
  required: true,
76
76
  });
77
77
  }
78
- for (const [token, fallback] of Object.entries(contract.derived)) {
79
- if (!theme.declarations.has(token) &&
80
- !theme.declarations.has(fallback)) {
78
+ // A derived token falls back to its source, and to the source's own
79
+ // source when that one is derived too, until a declared token.
80
+ const hasFallback = (token, seen) => {
81
+ if (theme.declarations.has(token))
82
+ return true;
83
+ const source = contract.derived[token];
84
+ if (source === undefined || seen.has(token))
85
+ return false;
86
+ return hasFallback(source, new Set([...seen, token]));
87
+ };
88
+ for (const token of Object.keys(contract.derived)) {
89
+ if (!hasFallback(token, new Set())) {
81
90
  result.violations.push({
82
91
  ...location,
83
92
  anchor: theme.name,
@@ -4,8 +4,9 @@ import type { CheckRule } from "../scan.js";
4
4
  export type { ContrastManifest, ContrastPair } from "../contracts.js";
5
5
  export interface TokenContrastOptions {
6
6
  computed?: Record<string, Record<string, string>>;
7
+ derived?: Record<string, string>;
7
8
  manifest: ContrastManifest;
8
9
  themes: ThemeConfig[];
9
10
  }
10
11
  export declare function loadContrastManifest(url?: URL): Promise<ContrastManifest>;
11
- export declare function createTokenContrastRule({ computed, manifest, themes, }: TokenContrastOptions): CheckRule;
12
+ export declare function createTokenContrastRule({ computed, derived, manifest, themes, }: TokenContrastOptions): CheckRule;
@@ -84,7 +84,7 @@ function dynamicStyleResult(file) {
84
84
  sourceFile.forEachChild(visit);
85
85
  return result;
86
86
  }
87
- export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
87
+ export function createTokenContrastRule({ computed = {}, derived = {}, manifest, themes, }) {
88
88
  const collector = createThemeCollector(themes);
89
89
  return {
90
90
  name: "tokens/contrast",
@@ -130,6 +130,14 @@ export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
130
130
  for (const [name, value] of Object.entries(computedTheme)) {
131
131
  tokens.set(name, value);
132
132
  }
133
+ // A derived token the theme leaves undeclared takes its documented
134
+ // default, the source token. resolveColor follows the chain and stops
135
+ // on a cycle.
136
+ for (const [token, source] of Object.entries(derived)) {
137
+ if (!tokens.has(token)) {
138
+ tokens.set(token, `var(${source})`);
139
+ }
140
+ }
133
141
  for (const pair of manifest.pairs) {
134
142
  const background = resolveColor(tokens.get(pair.background), tokens);
135
143
  const foreground = resolveColor(tokens.get(pair.foreground), tokens);
package/dist/scan.js CHANGED
@@ -3,7 +3,7 @@ import { extname, relative, resolve } from "node:path";
3
3
  import postcss from "postcss";
4
4
  import { computeLineStarts, createScanner, LanguageVariant, SyntaxKind, } from "typescript/unstable/ast";
5
5
  import { API } from "typescript/unstable/sync";
6
- import { resolveContractSet } from "./contracts.js";
6
+ import { CheckSetupError, resolveContractSet, } from "./contracts.js";
7
7
  import { loadComputedEvidence } from "./evidence.js";
8
8
  import { assignFingerprints } from "./baseline.js";
9
9
  import { applyDebtControls } from "./governance.js";
@@ -234,7 +234,7 @@ export async function runChecks(config, options = {}) {
234
234
  }
235
235
  rules = [...(await configuredRules)].sort((left, right) => left.name.localeCompare(right.name));
236
236
  }
237
- catch {
237
+ catch (error) {
238
238
  const report = emptyReport(["coverage/dynamic-expression"]);
239
239
  if (contracts) {
240
240
  report.contracts = {
@@ -243,9 +243,14 @@ export async function runChecks(config, options = {}) {
243
243
  theme: contracts.identities.theme,
244
244
  };
245
245
  }
246
+ // Only the default rules say why: their causes are written by this package
247
+ // and repeat nothing of the product. Rules supplied by the caller, and any
248
+ // failure nobody anticipated, stay behind the generic message.
246
249
  report.operationalErrors.push({
247
250
  code: usesDefaultRules ? "config" : "internal",
248
- message: "Unable to initialize configured rules.",
251
+ message: usesDefaultRules && error instanceof CheckSetupError
252
+ ? error.message
253
+ : "Unable to initialize configured rules.",
249
254
  });
250
255
  report.exitCode = 2;
251
256
  report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "2.0.0",
2
+ "contractVersion": "2.2.0",
3
3
  "schemaVersion": 1,
4
4
  "prefix": "--ui-",
5
5
  "required": [
@@ -94,7 +94,12 @@
94
94
  "--ui-status-info-border": "--ui-status-info",
95
95
  "--ui-field-height": "--ui-control-height-md",
96
96
  "--ui-dialog-title-font-size": "--ui-font-size-xl",
97
- "--ui-button-font-weight": "--ui-font-weight-strong"
97
+ "--ui-button-font-weight": "--ui-font-weight-strong",
98
+ "--ui-button-font-weight-sm": "--ui-button-font-weight",
99
+ "--ui-button-font-weight-md": "--ui-button-font-weight",
100
+ "--ui-button-font-weight-lg": "--ui-button-font-weight",
101
+ "--ui-control-checked-bg": "--ui-action-primary-bg",
102
+ "--ui-control-checked-fg": "--ui-action-primary-text"
98
103
  },
99
104
  "brandComposition": [
100
105
  "--ui-brand-primary",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksmv/ui-checks",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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",