design-constraint-validator 2.0.1 → 2.2.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/README.md +89 -23
- package/cli/commands/build.d.ts.map +1 -1
- package/cli/commands/build.js +32 -24
- package/cli/commands/build.ts +26 -17
- package/cli/commands/graph.d.ts.map +1 -1
- package/cli/commands/graph.js +35 -18
- package/cli/commands/graph.ts +30 -17
- package/cli/commands/patch-apply.d.ts.map +1 -1
- package/cli/commands/patch-apply.js +4 -1
- package/cli/commands/patch-apply.ts +4 -1
- package/cli/commands/set.d.ts.map +1 -1
- package/cli/commands/set.js +18 -19
- package/cli/commands/set.ts +19 -19
- package/cli/commands/utils.d.ts +1 -0
- package/cli/commands/utils.d.ts.map +1 -1
- package/cli/commands/utils.js +20 -1
- package/cli/commands/utils.ts +23 -1
- package/cli/commands/validate.d.ts.map +1 -1
- package/cli/commands/validate.js +45 -23
- package/cli/commands/validate.ts +47 -26
- package/cli/commands/why.d.ts.map +1 -1
- package/cli/commands/why.js +22 -10
- package/cli/commands/why.ts +20 -9
- package/cli/config-schema.d.ts +171 -166
- package/cli/config-schema.d.ts.map +1 -1
- package/cli/config-schema.js +29 -7
- package/cli/config-schema.ts +31 -7
- package/cli/config.d.ts.map +1 -1
- package/cli/config.js +8 -2
- package/cli/config.ts +8 -2
- package/cli/constraint-registry.d.ts +16 -0
- package/cli/constraint-registry.d.ts.map +1 -1
- package/cli/constraint-registry.js +115 -44
- package/cli/constraint-registry.ts +118 -47
- package/cli/cross-axis-loader.d.ts +62 -0
- package/cli/cross-axis-loader.d.ts.map +1 -1
- package/cli/cross-axis-loader.js +186 -31
- package/cli/cross-axis-loader.ts +199 -24
- package/cli/dcv.js +31 -25
- package/cli/dcv.ts +31 -21
- package/cli/json-output.d.ts +3 -1
- package/cli/json-output.d.ts.map +1 -1
- package/cli/json-output.js +11 -4
- package/cli/json-output.ts +13 -4
- package/cli/types.d.ts +21 -9
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +25 -10
- package/cli/validate-api.d.ts +40 -0
- package/cli/validate-api.d.ts.map +1 -0
- package/cli/validate-api.js +90 -0
- package/cli/validate-api.ts +131 -0
- package/core/breakpoints.d.ts +8 -2
- package/core/breakpoints.d.ts.map +1 -1
- package/core/breakpoints.js +24 -3
- package/core/breakpoints.ts +22 -3
- package/core/color.js +4 -4
- package/core/color.ts +4 -4
- package/core/constraints/cross-axis.d.ts.map +1 -1
- package/core/constraints/cross-axis.js +37 -9
- package/core/constraints/cross-axis.ts +37 -9
- package/core/constraints/monotonic-lightness.d.ts.map +1 -1
- package/core/constraints/monotonic-lightness.js +9 -5
- package/core/constraints/monotonic-lightness.ts +9 -4
- package/core/constraints/monotonic.d.ts.map +1 -1
- package/core/constraints/monotonic.js +32 -8
- package/core/constraints/monotonic.ts +29 -8
- package/core/constraints/threshold.d.ts.map +1 -1
- package/core/constraints/threshold.js +24 -4
- package/core/constraints/threshold.ts +23 -4
- package/core/constraints/wcag.d.ts.map +1 -1
- package/core/constraints/wcag.js +7 -1
- package/core/constraints/wcag.ts +7 -1
- package/core/dtcg.d.ts +38 -0
- package/core/dtcg.d.ts.map +1 -0
- package/core/dtcg.js +88 -0
- package/core/dtcg.ts +102 -0
- package/core/engine.d.ts +6 -0
- package/core/engine.d.ts.map +1 -1
- package/core/engine.ts +7 -0
- package/core/flatten.d.ts +5 -3
- package/core/flatten.d.ts.map +1 -1
- package/core/flatten.js +32 -10
- package/core/flatten.ts +48 -16
- package/core/image-export.d.ts.map +1 -1
- package/core/image-export.js +10 -7
- package/core/image-export.ts +9 -6
- package/core/index.d.ts +2 -0
- package/core/index.d.ts.map +1 -1
- package/core/index.js +4 -0
- package/core/index.ts +6 -0
- package/core/poset.d.ts +6 -1
- package/core/poset.d.ts.map +1 -1
- package/core/poset.js +7 -2
- package/core/poset.ts +7 -2
- package/core/why.d.ts +1 -1
- package/core/why.d.ts.map +1 -1
- package/core/why.ts +1 -1
- package/mcp/contracts.d.ts +1561 -0
- package/mcp/contracts.d.ts.map +1 -0
- package/mcp/contracts.js +74 -0
- package/mcp/contracts.ts +105 -0
- package/mcp/index.d.ts +11 -0
- package/mcp/index.d.ts.map +1 -0
- package/mcp/index.js +35 -0
- package/mcp/index.ts +97 -0
- package/mcp/insights.d.ts +94 -0
- package/mcp/insights.d.ts.map +1 -0
- package/mcp/insights.js +445 -0
- package/mcp/insights.ts +541 -0
- package/mcp/tools.d.ts +63 -0
- package/mcp/tools.d.ts.map +1 -0
- package/mcp/tools.js +299 -0
- package/mcp/tools.ts +431 -0
- package/package.json +36 -26
- package/server.json +21 -0
- package/cli/constraints-loader.d.ts.map +0 -1
- package/cli/engine-helpers.d.ts.map +0 -1
- package/core/cross-axis-config.d.ts.map +0 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic validation API.
|
|
3
|
+
*
|
|
4
|
+
* A thin convenience wrapper over the same flatten + engine + constraint-registry
|
|
5
|
+
* machinery the CLI uses, so library consumers (and the MCP server) get one call
|
|
6
|
+
* that takes tokens + constraints and returns structured violations — no argv, no
|
|
7
|
+
* process.exit, no prose.
|
|
8
|
+
*
|
|
9
|
+
* Re-exported from the package root (`design-constraint-validator`) via
|
|
10
|
+
* `core/index.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { flattenTokens } from '../core/flatten.js';
|
|
14
|
+
import { Engine } from '../core/engine.js';
|
|
15
|
+
import { loadConfig } from './config.js';
|
|
16
|
+
import { validateConfig } from './config-schema.js';
|
|
17
|
+
import { setupConstraints, collectReferencedIds } from './constraint-registry.js';
|
|
18
|
+
import { formatViolation } from './json-output.js';
|
|
19
|
+
function readTokensFile(p) {
|
|
20
|
+
if (!fs.existsSync(p)) {
|
|
21
|
+
throw new Error(`Tokens file not found: ${p}`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
28
|
+
throw new Error(`Tokens file is not valid JSON: ${p} (${detail})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function resolveConfig(input) {
|
|
32
|
+
if (input.constraints !== undefined) {
|
|
33
|
+
const { value, errors } = validateConfig({ constraints: input.constraints });
|
|
34
|
+
if (errors) {
|
|
35
|
+
throw new Error(`Inline constraints validation failed:\n - ${errors.join('\n - ')}`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
const res = loadConfig(input.configPath);
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(res.error);
|
|
42
|
+
}
|
|
43
|
+
return res.value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validate a token set against constraints and return structured results.
|
|
47
|
+
*
|
|
48
|
+
* Unlike the CLI, this treats `tokens` / `tokensPath` as the complete token set
|
|
49
|
+
* (no implicit cwd token overrides), so it is safe to call from a server that
|
|
50
|
+
* cannot share a filesystem with the caller.
|
|
51
|
+
*/
|
|
52
|
+
export function validate(input = {}) {
|
|
53
|
+
const tokens = input.tokens !== undefined
|
|
54
|
+
? input.tokens
|
|
55
|
+
: input.tokensPath !== undefined
|
|
56
|
+
? readTokensFile(input.tokensPath)
|
|
57
|
+
: {};
|
|
58
|
+
const config = resolveConfig(input);
|
|
59
|
+
const { flat, edges } = flattenTokens(tokens);
|
|
60
|
+
const init = {};
|
|
61
|
+
for (const t of Object.values(flat)) {
|
|
62
|
+
init[t.id] = t.value;
|
|
63
|
+
}
|
|
64
|
+
const engine = new Engine(init, edges);
|
|
65
|
+
const knownIds = new Set(Object.keys(init));
|
|
66
|
+
const sources = setupConstraints(engine, { config, bp: input.breakpoint, constraintsDir: input.constraintsDir ?? 'themes' }, { knownIds });
|
|
67
|
+
const issues = engine.evaluate(knownIds);
|
|
68
|
+
const errors = issues.filter((i) => i.level === 'error');
|
|
69
|
+
const warnings = issues.filter((i) => i.level !== 'error');
|
|
70
|
+
// No-match note: validated tokens that no active constraint references.
|
|
71
|
+
const coverage = collectReferencedIds(sources);
|
|
72
|
+
let matched = false;
|
|
73
|
+
for (const id of coverage.ids) {
|
|
74
|
+
if (knownIds.has(id)) {
|
|
75
|
+
matched = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const note = knownIds.size > 0 && coverage.coverageKnown && !matched
|
|
80
|
+
? `No active constraint references any of the ${knownIds.size} validated token(s) — nothing was checked. ` +
|
|
81
|
+
`Define constraints (constraints.wcag / constraints.thresholds) or point constraintsDir at your order/cross-axis files.`
|
|
82
|
+
: undefined;
|
|
83
|
+
return {
|
|
84
|
+
ok: errors.length === 0,
|
|
85
|
+
counts: { checked: issues.length, violations: errors.length, warnings: warnings.length },
|
|
86
|
+
violations: errors.map(formatViolation),
|
|
87
|
+
warnings: warnings.map(formatViolation),
|
|
88
|
+
note,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic validation API.
|
|
3
|
+
*
|
|
4
|
+
* A thin convenience wrapper over the same flatten + engine + constraint-registry
|
|
5
|
+
* machinery the CLI uses, so library consumers (and the MCP server) get one call
|
|
6
|
+
* that takes tokens + constraints and returns structured violations — no argv, no
|
|
7
|
+
* process.exit, no prose.
|
|
8
|
+
*
|
|
9
|
+
* Re-exported from the package root (`design-constraint-validator`) via
|
|
10
|
+
* `core/index.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { flattenTokens, type TokenNode, type FlatToken } from '../core/flatten.js';
|
|
14
|
+
import { Engine, type ConstraintIssue } from '../core/engine.js';
|
|
15
|
+
import type { Breakpoint } from '../core/breakpoints.js';
|
|
16
|
+
import { loadConfig } from './config.js';
|
|
17
|
+
import { validateConfig } from './config-schema.js';
|
|
18
|
+
import { setupConstraints, collectReferencedIds } from './constraint-registry.js';
|
|
19
|
+
import { formatViolation, type ConstraintViolation } from './json-output.js';
|
|
20
|
+
import type { DcvConfig } from './types.js';
|
|
21
|
+
|
|
22
|
+
export interface ValidateInput {
|
|
23
|
+
/** Inline tokens object (DTCG-style `$value`/`$type`). Takes precedence over `tokensPath`. */
|
|
24
|
+
tokens?: TokenNode;
|
|
25
|
+
/** Path to a tokens file. Used when `tokens` is not provided. */
|
|
26
|
+
tokensPath?: string;
|
|
27
|
+
/** Inline constraints config (the `constraints` block of dcv.config.json). Takes precedence over `configPath`. */
|
|
28
|
+
constraints?: DcvConfig['constraints'];
|
|
29
|
+
/** Path to a config file. When neither `constraints` nor `configPath` is given, a dcv.config.* in cwd is discovered. */
|
|
30
|
+
configPath?: string;
|
|
31
|
+
/** Directory holding order / cross-axis constraint files. Defaults to `themes`. */
|
|
32
|
+
constraintsDir?: string;
|
|
33
|
+
/** Optional breakpoint, selecting `<axis>.<bp>.order.json` / cross-axis variants. */
|
|
34
|
+
breakpoint?: Breakpoint;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ValidateResult {
|
|
38
|
+
/** True when there are no error-level violations. */
|
|
39
|
+
ok: boolean;
|
|
40
|
+
counts: { checked: number; violations: number; warnings: number };
|
|
41
|
+
violations: ConstraintViolation[];
|
|
42
|
+
warnings: ConstraintViolation[];
|
|
43
|
+
/** Set when tokens were validated but no active constraint referenced any of them. */
|
|
44
|
+
note?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readTokensFile(p: string): TokenNode {
|
|
48
|
+
if (!fs.existsSync(p)) {
|
|
49
|
+
throw new Error(`Tokens file not found: ${p}`);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')) as TokenNode;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
55
|
+
throw new Error(`Tokens file is not valid JSON: ${p} (${detail})`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveConfig(input: ValidateInput): DcvConfig {
|
|
60
|
+
if (input.constraints !== undefined) {
|
|
61
|
+
const { value, errors } = validateConfig({ constraints: input.constraints });
|
|
62
|
+
if (errors) {
|
|
63
|
+
throw new Error(`Inline constraints validation failed:\n - ${errors.join('\n - ')}`);
|
|
64
|
+
}
|
|
65
|
+
return value!;
|
|
66
|
+
}
|
|
67
|
+
const res = loadConfig(input.configPath);
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(res.error);
|
|
70
|
+
}
|
|
71
|
+
return res.value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate a token set against constraints and return structured results.
|
|
76
|
+
*
|
|
77
|
+
* Unlike the CLI, this treats `tokens` / `tokensPath` as the complete token set
|
|
78
|
+
* (no implicit cwd token overrides), so it is safe to call from a server that
|
|
79
|
+
* cannot share a filesystem with the caller.
|
|
80
|
+
*/
|
|
81
|
+
export function validate(input: ValidateInput = {}): ValidateResult {
|
|
82
|
+
const tokens: TokenNode =
|
|
83
|
+
input.tokens !== undefined
|
|
84
|
+
? input.tokens
|
|
85
|
+
: input.tokensPath !== undefined
|
|
86
|
+
? readTokensFile(input.tokensPath)
|
|
87
|
+
: {};
|
|
88
|
+
|
|
89
|
+
const config = resolveConfig(input);
|
|
90
|
+
|
|
91
|
+
const { flat, edges } = flattenTokens(tokens);
|
|
92
|
+
const init: Record<string, string | number> = {};
|
|
93
|
+
for (const t of Object.values(flat)) {
|
|
94
|
+
init[(t as FlatToken).id] = (t as FlatToken).value;
|
|
95
|
+
}
|
|
96
|
+
const engine = new Engine(init, edges);
|
|
97
|
+
const knownIds = new Set(Object.keys(init));
|
|
98
|
+
|
|
99
|
+
const sources = setupConstraints(
|
|
100
|
+
engine,
|
|
101
|
+
{ config, bp: input.breakpoint, constraintsDir: input.constraintsDir ?? 'themes' },
|
|
102
|
+
{ knownIds },
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const issues = engine.evaluate(knownIds);
|
|
106
|
+
const errors = issues.filter((i: ConstraintIssue) => i.level === 'error');
|
|
107
|
+
const warnings = issues.filter((i: ConstraintIssue) => i.level !== 'error');
|
|
108
|
+
|
|
109
|
+
// No-match note: validated tokens that no active constraint references.
|
|
110
|
+
const coverage = collectReferencedIds(sources);
|
|
111
|
+
let matched = false;
|
|
112
|
+
for (const id of coverage.ids) {
|
|
113
|
+
if (knownIds.has(id)) {
|
|
114
|
+
matched = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const note =
|
|
119
|
+
knownIds.size > 0 && coverage.coverageKnown && !matched
|
|
120
|
+
? `No active constraint references any of the ${knownIds.size} validated token(s) — nothing was checked. ` +
|
|
121
|
+
`Define constraints (constraints.wcag / constraints.thresholds) or point constraintsDir at your order/cross-axis files.`
|
|
122
|
+
: undefined;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: errors.length === 0,
|
|
126
|
+
counts: { checked: issues.length, violations: errors.length, warnings: warnings.length },
|
|
127
|
+
violations: errors.map(formatViolation),
|
|
128
|
+
warnings: warnings.map(formatViolation),
|
|
129
|
+
note,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/core/breakpoints.d.ts
CHANGED
|
@@ -6,7 +6,13 @@ export declare function loadOrders(axis: string, bp?: Breakpoint): [string, "<="
|
|
|
6
6
|
export declare function mergeTokens(base: unknown, overlay: unknown): TokenNode;
|
|
7
7
|
/** Load tokens with optional breakpoint override: base + overrides/<bp>.json */
|
|
8
8
|
/**
|
|
9
|
-
* Load tokens with override precedence: base < local < breakpoint
|
|
9
|
+
* Load tokens with override precedence: base < local < breakpoint.
|
|
10
|
+
*
|
|
11
|
+
* @param bp optional breakpoint overlay (tokens/overrides/<bp>.json).
|
|
12
|
+
* @param tokensPath explicit base tokens file. When provided, a missing or
|
|
13
|
+
* invalid file throws — callers must never silently validate
|
|
14
|
+
* an empty token set. When omitted, defaults to the repo
|
|
15
|
+
* example file and stays lenient (backward-compatible).
|
|
10
16
|
*/
|
|
11
|
-
export declare function loadTokensWithBreakpoint(bp?: Breakpoint): TokenNode;
|
|
17
|
+
export declare function loadTokensWithBreakpoint(bp?: Breakpoint, tokensPath?: string): TokenNode;
|
|
12
18
|
//# sourceMappingURL=breakpoints.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"breakpoints.d.ts","sourceRoot":"","sources":["breakpoints.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE5C,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,CAM7D;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOhE;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,UAAU,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,CAKzF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,CAQtE;AAED,gFAAgF;AAChF
|
|
1
|
+
{"version":3,"file":"breakpoints.d.ts","sourceRoot":"","sources":["breakpoints.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE5C,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,CAM7D;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOhE;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,UAAU,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,CAKzF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,CAQtE;AAED,gFAAgF;AAChF;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAkBxF"}
|
package/core/breakpoints.js
CHANGED
|
@@ -38,10 +38,31 @@ export function mergeTokens(base, overlay) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Load tokens with optional breakpoint override: base + overrides/<bp>.json */
|
|
40
40
|
/**
|
|
41
|
-
* Load tokens with override precedence: base < local < breakpoint
|
|
41
|
+
* Load tokens with override precedence: base < local < breakpoint.
|
|
42
|
+
*
|
|
43
|
+
* @param bp optional breakpoint overlay (tokens/overrides/<bp>.json).
|
|
44
|
+
* @param tokensPath explicit base tokens file. When provided, a missing or
|
|
45
|
+
* invalid file throws — callers must never silently validate
|
|
46
|
+
* an empty token set. When omitted, defaults to the repo
|
|
47
|
+
* example file and stays lenient (backward-compatible).
|
|
42
48
|
*/
|
|
43
|
-
export function loadTokensWithBreakpoint(bp) {
|
|
44
|
-
|
|
49
|
+
export function loadTokensWithBreakpoint(bp, tokensPath) {
|
|
50
|
+
let base;
|
|
51
|
+
if (tokensPath !== undefined) {
|
|
52
|
+
if (!fs.existsSync(tokensPath)) {
|
|
53
|
+
throw new Error(`Tokens file not found: ${tokensPath}`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
base = JSON.parse(fs.readFileSync(tokensPath, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
60
|
+
throw new Error(`Tokens file is not valid JSON: ${tokensPath} (${detail})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
base = loadJsonSafe("tokens/tokens.example.json") ?? {};
|
|
65
|
+
}
|
|
45
66
|
const local = loadJsonSafe("tokens/overrides/local.json");
|
|
46
67
|
const ov = bp ? loadJsonSafe(`tokens/overrides/${bp}.json`) : null;
|
|
47
68
|
return mergeTokens(mergeTokens(base, local), ov);
|
package/core/breakpoints.ts
CHANGED
|
@@ -40,10 +40,29 @@ export function mergeTokens(base: unknown, overlay: unknown): TokenNode {
|
|
|
40
40
|
|
|
41
41
|
/** Load tokens with optional breakpoint override: base + overrides/<bp>.json */
|
|
42
42
|
/**
|
|
43
|
-
* Load tokens with override precedence: base < local < breakpoint
|
|
43
|
+
* Load tokens with override precedence: base < local < breakpoint.
|
|
44
|
+
*
|
|
45
|
+
* @param bp optional breakpoint overlay (tokens/overrides/<bp>.json).
|
|
46
|
+
* @param tokensPath explicit base tokens file. When provided, a missing or
|
|
47
|
+
* invalid file throws — callers must never silently validate
|
|
48
|
+
* an empty token set. When omitted, defaults to the repo
|
|
49
|
+
* example file and stays lenient (backward-compatible).
|
|
44
50
|
*/
|
|
45
|
-
export function loadTokensWithBreakpoint(bp?: Breakpoint): TokenNode {
|
|
46
|
-
|
|
51
|
+
export function loadTokensWithBreakpoint(bp?: Breakpoint, tokensPath?: string): TokenNode {
|
|
52
|
+
let base: TokenNode;
|
|
53
|
+
if (tokensPath !== undefined) {
|
|
54
|
+
if (!fs.existsSync(tokensPath)) {
|
|
55
|
+
throw new Error(`Tokens file not found: ${tokensPath}`);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
base = JSON.parse(fs.readFileSync(tokensPath, "utf8")) as TokenNode;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
61
|
+
throw new Error(`Tokens file is not valid JSON: ${tokensPath} (${detail})`);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
base = loadJsonSafe<TokenNode>("tokens/tokens.example.json") ?? {};
|
|
65
|
+
}
|
|
47
66
|
const local = loadJsonSafe<TokenNode>("tokens/overrides/local.json");
|
|
48
67
|
const ov = bp ? loadJsonSafe<TokenNode>(`tokens/overrides/${bp}.json`) : null;
|
|
49
68
|
return mergeTokens(mergeTokens(base, local), ov);
|
package/core/color.js
CHANGED
|
@@ -74,7 +74,7 @@ export function parseCssColor(input) {
|
|
|
74
74
|
const C = Math.max(0, num(m[2]));
|
|
75
75
|
const h = ((num(m[3]) % 360) + 360) % 360;
|
|
76
76
|
const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
|
|
77
|
-
const [r, g, b] =
|
|
77
|
+
const [r, g, b] = oklchToLinearSrgb(L, C, h).map(v => linToSrgb(v));
|
|
78
78
|
return { r, g, b, a: clamp01(a) };
|
|
79
79
|
}
|
|
80
80
|
// named "transparent"
|
|
@@ -103,8 +103,8 @@ function hslToRgb(H, S, L) {
|
|
|
103
103
|
const m = L - C / 2;
|
|
104
104
|
return { r: clamp255((r + m) * 255), g: clamp255((g + m) * 255), b: clamp255((b + m) * 255), a: 1 };
|
|
105
105
|
}
|
|
106
|
-
/* ---------- OKLCH → sRGB (0..1 channels) ---------- */
|
|
107
|
-
function
|
|
106
|
+
/* ---------- OKLCH → linear sRGB (0..1 channels) ---------- */
|
|
107
|
+
function oklchToLinearSrgb(L, C, hDeg) {
|
|
108
108
|
const h = (hDeg * Math.PI) / 180;
|
|
109
109
|
// OKLCH -> OKLab
|
|
110
110
|
const a = C * Math.cos(h);
|
|
@@ -118,7 +118,7 @@ function oklchToSrgb(L, C, hDeg) {
|
|
|
118
118
|
const R = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
119
119
|
const G = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
120
120
|
const B = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
121
|
-
// clamp to [0,1] (gamut clip)
|
|
121
|
+
// clamp to [0,1] (gamut clip). Callers gamma-encode to sRGB.
|
|
122
122
|
return [clamp01(R), clamp01(G), clamp01(B)];
|
|
123
123
|
}
|
|
124
124
|
/* ---------- Alpha compositing (linear light) ---------- */
|
package/core/color.ts
CHANGED
|
@@ -87,7 +87,7 @@ export function parseCssColor(input: string | undefined | null): RGBA | null {
|
|
|
87
87
|
const C = Math.max(0, num(m[2]));
|
|
88
88
|
const h = ((num(m[3]) % 360) + 360) % 360;
|
|
89
89
|
const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
|
|
90
|
-
const [r, g, b] =
|
|
90
|
+
const [r, g, b] = oklchToLinearSrgb(L, C, h).map(v => linToSrgb(v));
|
|
91
91
|
return { r, g, b, a: clamp01(a) };
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -113,8 +113,8 @@ function hslToRgb(H: number, S: number, L: number): RGBA {
|
|
|
113
113
|
return { r: clamp255((r + m) * 255), g: clamp255((g + m) * 255), b: clamp255((b + m) * 255), a: 1 };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/* ---------- OKLCH → sRGB (0..1 channels) ---------- */
|
|
117
|
-
function
|
|
116
|
+
/* ---------- OKLCH → linear sRGB (0..1 channels) ---------- */
|
|
117
|
+
function oklchToLinearSrgb(L: number, C: number, hDeg: number): [number, number, number] {
|
|
118
118
|
const h = (hDeg * Math.PI) / 180;
|
|
119
119
|
// OKLCH -> OKLab
|
|
120
120
|
const a = C * Math.cos(h);
|
|
@@ -128,7 +128,7 @@ function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, numbe
|
|
|
128
128
|
const R = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
129
129
|
const G = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
130
130
|
const B = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
131
|
-
// clamp to [0,1] (gamut clip)
|
|
131
|
+
// clamp to [0,1] (gamut clip). Callers gamma-encode to sRGB.
|
|
132
132
|
return [clamp01(R), clamp01(G), clamp01(B)];
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cross-axis.d.ts","sourceRoot":"","sources":["cross-axis.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GACrB;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;KAAE,CAAC;IACnD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;QAAC,GAAG,EAAE,CAAC,CAAC,EAAC,MAAM,EAAE,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACrG,GACD;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAI,MAAM,CAAC;QAAC,KAAK,EAAE,CAAC,IAAI,EAAC,MAAM,EAAC,EAAE,EAAC,MAAM,EAAC,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACvH,CAAC;AAEN,MAAM,MAAM,GAAG,GAAG;IAChB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;
|
|
1
|
+
{"version":3,"file":"cross-axis.d.ts","sourceRoot":"","sources":["cross-axis.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GACrB;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;KAAE,CAAC;IACnD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;QAAC,GAAG,EAAE,CAAC,CAAC,EAAC,MAAM,EAAE,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACrG,GACD;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAI,MAAM,CAAC;QAAC,KAAK,EAAE,CAAC,IAAI,EAAC,MAAM,EAAC,EAAE,EAAC,MAAM,EAAC,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACvH,CAAC;AAEN,MAAM,MAAM,GAAG,GAAG;IAChB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAsCF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAgErF;AAGD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,aAAa,EAAE,CAkBlG"}
|
|
@@ -1,23 +1,40 @@
|
|
|
1
|
+
// Shares the hardened finite-size policy used by monotonic/threshold (TASK-037):
|
|
2
|
+
// bare numbers / unitless as px, `rem`/`em` as 16px-relative, real numbers only
|
|
3
|
+
// (rejects ".", "5.", "1.2.3px"), and non-finite guarded. The one addition over
|
|
4
|
+
// the canonical parser is a clamp()/min()/max()/calc() heuristic — but it is
|
|
5
|
+
// gated on a `(` so a malformed bare value like "1.2.3px" is rejected rather
|
|
6
|
+
// than silently yielding a partial parse.
|
|
1
7
|
const px = (v) => {
|
|
2
8
|
if (typeof v === 'number')
|
|
3
|
-
return v;
|
|
9
|
+
return Number.isFinite(v) ? v : null;
|
|
4
10
|
if (typeof v !== 'string')
|
|
5
11
|
return null;
|
|
6
12
|
const trimmed = v.trim();
|
|
7
|
-
// Direct simple form
|
|
8
|
-
|
|
13
|
+
// Direct simple form: bare number / px / rem / em.
|
|
14
|
+
const m = trimmed.match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
9
15
|
if (m) {
|
|
10
16
|
const n = parseFloat(m[1]);
|
|
11
|
-
|
|
17
|
+
if (!Number.isFinite(n))
|
|
18
|
+
return null;
|
|
19
|
+
const unit = (m[2] || 'px').toLowerCase();
|
|
20
|
+
return unit === 'rem' || unit === 'em' ? n * 16 : n;
|
|
12
21
|
}
|
|
13
|
-
// Heuristic:
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
const
|
|
17
|
-
|
|
22
|
+
// Heuristic: first px/rem/em token inside a CSS function expression (clamp(),
|
|
23
|
+
// min(), max(), calc(), …). Gated on `(` so garbage simple values don't leak.
|
|
24
|
+
if (trimmed.includes('(')) {
|
|
25
|
+
const inner = trimmed.match(/(\d*\.?\d+)\s*(px|rem|em)/i);
|
|
26
|
+
if (inner) {
|
|
27
|
+
const n = parseFloat(inner[1]);
|
|
28
|
+
if (!Number.isFinite(n))
|
|
29
|
+
return null;
|
|
30
|
+
return inner[2].toLowerCase() === 'px' ? n : n * 16;
|
|
31
|
+
}
|
|
18
32
|
}
|
|
19
33
|
return null;
|
|
20
34
|
};
|
|
35
|
+
// Distinguish "operand absent" (skip silently) from "operand present but
|
|
36
|
+
// unparseable" (warn loudly) — a silent skip of a present value is a false green.
|
|
37
|
+
const presentButUnparseable = (raw) => raw != null && raw !== '' && px(raw) === null;
|
|
21
38
|
export function CrossAxisPlugin(rules, bp) {
|
|
22
39
|
return {
|
|
23
40
|
id: "cross-axis",
|
|
@@ -33,6 +50,17 @@ export function CrossAxisPlugin(rules, bp) {
|
|
|
33
50
|
// Evaluate if either referenced id is among candidates (looser gating so global validate works)
|
|
34
51
|
if (!candidates.has(r.when.id) && !candidates.has(r.require.id))
|
|
35
52
|
continue;
|
|
53
|
+
// Present-but-unparseable operand → warn, don't silently skip (TASK-037).
|
|
54
|
+
if (presentButUnparseable(ctx.get(r.when.id)) || presentButUnparseable(ctx.get(r.require.id))) {
|
|
55
|
+
issues.push({
|
|
56
|
+
id: r.require.id,
|
|
57
|
+
rule: "cross-axis",
|
|
58
|
+
level: "warn",
|
|
59
|
+
where: r.where,
|
|
60
|
+
message: `Cannot check cross-axis rule "${r.id}": unparseable size value(s)`
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
36
64
|
const wv = ctx.getPx(r.when.id);
|
|
37
65
|
// compare-style loader rules set when.id = a, require.id = a; use same value if second missing
|
|
38
66
|
let rv = ctx.getPx(r.require.id);
|
|
@@ -18,25 +18,42 @@ export type Ctx = {
|
|
|
18
18
|
bp?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// Shares the hardened finite-size policy used by monotonic/threshold (TASK-037):
|
|
22
|
+
// bare numbers / unitless as px, `rem`/`em` as 16px-relative, real numbers only
|
|
23
|
+
// (rejects ".", "5.", "1.2.3px"), and non-finite guarded. The one addition over
|
|
24
|
+
// the canonical parser is a clamp()/min()/max()/calc() heuristic — but it is
|
|
25
|
+
// gated on a `(` so a malformed bare value like "1.2.3px" is rejected rather
|
|
26
|
+
// than silently yielding a partial parse.
|
|
21
27
|
const px = (v: unknown): number | null => {
|
|
22
|
-
if (typeof v === 'number') return v;
|
|
28
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
23
29
|
if (typeof v !== 'string') return null;
|
|
24
30
|
const trimmed = v.trim();
|
|
25
|
-
// Direct simple form
|
|
26
|
-
|
|
31
|
+
// Direct simple form: bare number / px / rem / em.
|
|
32
|
+
const m = trimmed.match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
27
33
|
if (m) {
|
|
28
34
|
const n = parseFloat(m[1]);
|
|
29
|
-
|
|
35
|
+
if (!Number.isFinite(n)) return null;
|
|
36
|
+
const unit = (m[2] || 'px').toLowerCase();
|
|
37
|
+
return unit === 'rem' || unit === 'em' ? n * 16 : n;
|
|
30
38
|
}
|
|
31
|
-
// Heuristic:
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
const
|
|
35
|
-
|
|
39
|
+
// Heuristic: first px/rem/em token inside a CSS function expression (clamp(),
|
|
40
|
+
// min(), max(), calc(), …). Gated on `(` so garbage simple values don't leak.
|
|
41
|
+
if (trimmed.includes('(')) {
|
|
42
|
+
const inner = trimmed.match(/(\d*\.?\d+)\s*(px|rem|em)/i);
|
|
43
|
+
if (inner) {
|
|
44
|
+
const n = parseFloat(inner[1]);
|
|
45
|
+
if (!Number.isFinite(n)) return null;
|
|
46
|
+
return inner[2].toLowerCase() === 'px' ? n : n * 16;
|
|
47
|
+
}
|
|
36
48
|
}
|
|
37
49
|
return null;
|
|
38
50
|
};
|
|
39
51
|
|
|
52
|
+
// Distinguish "operand absent" (skip silently) from "operand present but
|
|
53
|
+
// unparseable" (warn loudly) — a silent skip of a present value is a false green.
|
|
54
|
+
const presentButUnparseable = (raw: unknown): boolean =>
|
|
55
|
+
raw != null && raw !== '' && px(raw) === null;
|
|
56
|
+
|
|
40
57
|
export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): ConstraintPlugin {
|
|
41
58
|
return {
|
|
42
59
|
id: "cross-axis",
|
|
@@ -57,6 +74,17 @@ export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): Constraint
|
|
|
57
74
|
if ("when" in r) {
|
|
58
75
|
// Evaluate if either referenced id is among candidates (looser gating so global validate works)
|
|
59
76
|
if (!candidates.has(r.when.id) && !candidates.has(r.require.id)) continue;
|
|
77
|
+
// Present-but-unparseable operand → warn, don't silently skip (TASK-037).
|
|
78
|
+
if (presentButUnparseable(ctx.get(r.when.id)) || presentButUnparseable(ctx.get(r.require.id))) {
|
|
79
|
+
issues.push({
|
|
80
|
+
id: r.require.id,
|
|
81
|
+
rule: "cross-axis",
|
|
82
|
+
level: "warn",
|
|
83
|
+
where: r.where,
|
|
84
|
+
message: `Cannot check cross-axis rule "${r.id}": unparseable size value(s)`
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
60
88
|
const wv = ctx.getPx(r.when.id);
|
|
61
89
|
// compare-style loader rules set when.id = a, require.id = a; use same value if second missing
|
|
62
90
|
let rv = ctx.getPx(r.require.id);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monotonic-lightness.d.ts","sourceRoot":"","sources":["monotonic-lightness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"monotonic-lightness.d.ts","sourceRoot":"","sources":["monotonic-lightness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAYxD;AAED,MAAM,MAAM,KAAK,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;AAElD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,gBAAgB,CAuBpE"}
|
|
@@ -2,11 +2,15 @@ import { parseCssColor, relativeLuminance } from "../color.js";
|
|
|
2
2
|
export function parseLightness(v) {
|
|
3
3
|
if (typeof v !== "string")
|
|
4
4
|
return null;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// ONE consistent lightness scale for every format. parseCssColor handles
|
|
6
|
+
// hex / rgb / hsl / oklch (oklch→sRGB is the verified TASK-005 pipeline), so we
|
|
7
|
+
// always compare WCAG relative luminance.
|
|
8
|
+
//
|
|
9
|
+
// BUG FIXED (TASK-009): oklch() previously short-circuited to its raw perceptual
|
|
10
|
+
// L coordinate — a DIFFERENT scale from the relative luminance used for hex. A
|
|
11
|
+
// scale mixing the two formats then compared incomparable numbers (e.g. oklch L
|
|
12
|
+
// 0.60 vs hex luminance 0.216 for the same gray), yielding false pass/fail.
|
|
13
|
+
const rgba = parseCssColor(v.trim());
|
|
10
14
|
return rgba ? relativeLuminance(rgba) : null;
|
|
11
15
|
}
|
|
12
16
|
export function MonotonicLightness(orders) {
|
|
@@ -3,10 +3,15 @@ import { parseCssColor, relativeLuminance } from "../color.js";
|
|
|
3
3
|
|
|
4
4
|
export function parseLightness(v: unknown): number | null {
|
|
5
5
|
if (typeof v !== "string") return null;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// ONE consistent lightness scale for every format. parseCssColor handles
|
|
7
|
+
// hex / rgb / hsl / oklch (oklch→sRGB is the verified TASK-005 pipeline), so we
|
|
8
|
+
// always compare WCAG relative luminance.
|
|
9
|
+
//
|
|
10
|
+
// BUG FIXED (TASK-009): oklch() previously short-circuited to its raw perceptual
|
|
11
|
+
// L coordinate — a DIFFERENT scale from the relative luminance used for hex. A
|
|
12
|
+
// scale mixing the two formats then compared incomparable numbers (e.g. oklch L
|
|
13
|
+
// 0.60 vs hex luminance 0.216 for the same gray), yielding false pass/fail.
|
|
14
|
+
const rgba = parseCssColor(v.trim());
|
|
10
15
|
return rgba ? relativeLuminance(rgba) : null;
|
|
11
16
|
}
|
|
12
17
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monotonic.d.ts","sourceRoot":"","sources":["monotonic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,wBAAgB,eAAe,CAC7B,MAAM,EAAE,KAAK,EAAE,EACf,KAAK,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,EACpC,MAAM,SAAc,GACnB,gBAAgB,
|
|
1
|
+
{"version":3,"file":"monotonic.d.ts","sourceRoot":"","sources":["monotonic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,wBAAgB,eAAe,CAC7B,MAAM,EAAE,KAAK,EAAE,EACf,KAAK,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,EACpC,MAAM,SAAc,GACnB,gBAAgB,CAoClB;AAMD,eAAO,MAAM,SAAS,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAW/C,CAAC;AAGF,eAAO,MAAM,WAAW,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAOjD,CAAC;AAGF,eAAO,MAAM,cAAc,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAoBpD,CAAC"}
|
|
@@ -4,12 +4,27 @@ export function MonotonicPlugin(orders, parse, ruleId = "monotonic") {
|
|
|
4
4
|
evaluate(engine, candidates) {
|
|
5
5
|
const issues = [];
|
|
6
6
|
for (const [a, op, b] of orders) {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
if (!candidates.has(a) && !candidates.has(b))
|
|
8
|
+
continue; // incremental
|
|
9
|
+
const rawA = engine.get(a);
|
|
10
|
+
const rawB = engine.get(b);
|
|
11
|
+
const va = parse(rawA);
|
|
12
|
+
const vb = parse(rawB);
|
|
13
|
+
// A present-but-unparseable size operand (e.g. "50%", "10vw") can't be
|
|
14
|
+
// compared — warn instead of silently skipping (TASK-031).
|
|
15
|
+
if ((rawA != null && rawA !== "" && va == null) || (rawB != null && rawB !== "" && vb == null)) {
|
|
16
|
+
issues.push({
|
|
17
|
+
id: `${a}|${b}`,
|
|
18
|
+
rule: "monotonic",
|
|
19
|
+
level: "warn",
|
|
20
|
+
message: `Cannot check ${a} ${op} ${b}: unparseable size value(s)`
|
|
21
|
+
});
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
9
24
|
if (va == null || vb == null)
|
|
10
|
-
continue; //
|
|
25
|
+
continue; // operand absent — nothing to check
|
|
11
26
|
const ok = op === ">=" ? va >= vb : va <= vb;
|
|
12
|
-
if (!ok
|
|
27
|
+
if (!ok) {
|
|
13
28
|
issues.push({
|
|
14
29
|
id: `${a}|${b}`,
|
|
15
30
|
rule: "monotonic",
|
|
@@ -22,16 +37,25 @@ export function MonotonicPlugin(orders, parse, ruleId = "monotonic") {
|
|
|
22
37
|
}
|
|
23
38
|
};
|
|
24
39
|
}
|
|
25
|
-
// a
|
|
40
|
+
// Parse a size to px for comparison. Coerces bare numbers and unitless strings to
|
|
41
|
+
// px (TASK-031: numeric `$value` and aliases-to-numbers previously returned null,
|
|
42
|
+
// silently skipping the rule). `rem`/`em` are 16px-relative. Returns null only for
|
|
43
|
+
// genuinely unparseable operands (e.g. "50%", "10vw") — callers warn, not skip.
|
|
26
44
|
export const parseSize = (v) => {
|
|
45
|
+
if (typeof v === "number")
|
|
46
|
+
return Number.isFinite(v) ? v : null; // bare number == px
|
|
27
47
|
if (typeof v !== "string")
|
|
28
48
|
return null;
|
|
29
|
-
|
|
49
|
+
// Real number only: rejects ".", "5.", "1.2.3px" (which previously slipped
|
|
50
|
+
// through `[0-9.]+` + parseFloat as NaN/garbage and became spurious errors).
|
|
51
|
+
const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
30
52
|
if (!m)
|
|
31
53
|
return null;
|
|
32
54
|
const num = parseFloat(m[1]);
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
if (!Number.isFinite(num))
|
|
56
|
+
return null;
|
|
57
|
+
const unit = (m[2] || "px").toLowerCase();
|
|
58
|
+
return unit === "rem" || unit === "em" ? num * 16 : num; // px and unitless as-is
|
|
35
59
|
};
|
|
36
60
|
// Parser for unitless numbers (like scale factors)
|
|
37
61
|
export const parseNumber = (v) => {
|