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
|
@@ -12,11 +12,25 @@ export function MonotonicPlugin(
|
|
|
12
12
|
evaluate(engine, candidates) {
|
|
13
13
|
const issues = [];
|
|
14
14
|
for (const [a, op, b] of orders) {
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
15
|
+
if (!candidates.has(a) && !candidates.has(b)) continue; // incremental
|
|
16
|
+
const rawA = engine.get(a);
|
|
17
|
+
const rawB = engine.get(b);
|
|
18
|
+
const va = parse(rawA);
|
|
19
|
+
const vb = parse(rawB);
|
|
20
|
+
// A present-but-unparseable size operand (e.g. "50%", "10vw") can't be
|
|
21
|
+
// compared — warn instead of silently skipping (TASK-031).
|
|
22
|
+
if ((rawA != null && rawA !== "" && va == null) || (rawB != null && rawB !== "" && vb == null)) {
|
|
23
|
+
issues.push({
|
|
24
|
+
id: `${a}|${b}`,
|
|
25
|
+
rule: "monotonic",
|
|
26
|
+
level: "warn" as const,
|
|
27
|
+
message: `Cannot check ${a} ${op} ${b}: unparseable size value(s)`
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (va == null || vb == null) continue; // operand absent — nothing to check
|
|
18
32
|
const ok = op === ">=" ? va >= vb : va <= vb;
|
|
19
|
-
if (!ok
|
|
33
|
+
if (!ok) {
|
|
20
34
|
issues.push({
|
|
21
35
|
id: `${a}|${b}`,
|
|
22
36
|
rule: "monotonic",
|
|
@@ -30,14 +44,21 @@ export function MonotonicPlugin(
|
|
|
30
44
|
};
|
|
31
45
|
}
|
|
32
46
|
|
|
33
|
-
// a
|
|
47
|
+
// Parse a size to px for comparison. Coerces bare numbers and unitless strings to
|
|
48
|
+
// px (TASK-031: numeric `$value` and aliases-to-numbers previously returned null,
|
|
49
|
+
// silently skipping the rule). `rem`/`em` are 16px-relative. Returns null only for
|
|
50
|
+
// genuinely unparseable operands (e.g. "50%", "10vw") — callers warn, not skip.
|
|
34
51
|
export const parseSize = (v: unknown): number | null => {
|
|
52
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null; // bare number == px
|
|
35
53
|
if (typeof v !== "string") return null;
|
|
36
|
-
|
|
54
|
+
// Real number only: rejects ".", "5.", "1.2.3px" (which previously slipped
|
|
55
|
+
// through `[0-9.]+` + parseFloat as NaN/garbage and became spurious errors).
|
|
56
|
+
const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
37
57
|
if (!m) return null;
|
|
38
58
|
const num = parseFloat(m[1]);
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
if (!Number.isFinite(num)) return null;
|
|
60
|
+
const unit = (m[2] || "px").toLowerCase();
|
|
61
|
+
return unit === "rem" || unit === "em" ? num * 16 : num; // px and unitless as-is
|
|
41
62
|
};
|
|
42
63
|
|
|
43
64
|
// Parser for unitless numbers (like scale factors)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"threshold.d.ts","sourceRoot":"","sources":["threshold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC1B,CAAC;
|
|
1
|
+
{"version":3,"file":"threshold.d.ts","sourceRoot":"","sources":["threshold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC1B,CAAC;AAiBF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,SAAc,GAAG,gBAAgB,CAoC9F"}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
// Coerces bare numbers and unitless strings to px (TASK-031: numeric `$value`
|
|
2
|
+
// previously returned null, silently skipping the threshold). `rem`/`em` are
|
|
3
|
+
// 16px-relative. Returns null only for genuinely unparseable operands.
|
|
1
4
|
const parseSizePx = (v) => {
|
|
5
|
+
if (typeof v === "number")
|
|
6
|
+
return Number.isFinite(v) ? v : null;
|
|
2
7
|
if (typeof v !== "string")
|
|
3
8
|
return null;
|
|
4
|
-
|
|
9
|
+
// Real number only — rejects ".", "5.", "1.2.3px" (TASK-034).
|
|
10
|
+
const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
5
11
|
if (!m)
|
|
6
12
|
return null;
|
|
7
13
|
const n = parseFloat(m[1]);
|
|
14
|
+
if (!Number.isFinite(n))
|
|
15
|
+
return null;
|
|
8
16
|
const unit = (m[2] || "px").toLowerCase();
|
|
9
|
-
return unit === "rem" ? n * 16 : n;
|
|
17
|
+
return unit === "rem" || unit === "em" ? n * 16 : n;
|
|
10
18
|
};
|
|
11
19
|
export function ThresholdPlugin(rules, ruleId = "threshold") {
|
|
12
20
|
return {
|
|
@@ -16,9 +24,21 @@ export function ThresholdPlugin(rules, ruleId = "threshold") {
|
|
|
16
24
|
for (const r of rules) {
|
|
17
25
|
if (!candidates.has(r.id))
|
|
18
26
|
continue; // incremental
|
|
19
|
-
const
|
|
20
|
-
|
|
27
|
+
const raw = engine.get(r.id);
|
|
28
|
+
const px = parseSizePx(raw);
|
|
29
|
+
if (px == null) {
|
|
30
|
+
// Present-but-unparseable size — warn instead of silently skipping (TASK-031).
|
|
31
|
+
if (raw != null && raw !== "") {
|
|
32
|
+
issues.push({
|
|
33
|
+
id: r.id,
|
|
34
|
+
rule: ruleId,
|
|
35
|
+
level: "warn",
|
|
36
|
+
where: r.where,
|
|
37
|
+
message: `Cannot check ${r.id} ${r.op} ${r.valuePx}px: unparseable size value`
|
|
38
|
+
});
|
|
39
|
+
}
|
|
21
40
|
continue;
|
|
41
|
+
}
|
|
22
42
|
const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
|
|
23
43
|
if (!ok) {
|
|
24
44
|
issues.push({
|
|
@@ -8,13 +8,19 @@ export type ThresholdRule = {
|
|
|
8
8
|
level?: "error" | "warn"; // default error
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
// Coerces bare numbers and unitless strings to px (TASK-031: numeric `$value`
|
|
12
|
+
// previously returned null, silently skipping the threshold). `rem`/`em` are
|
|
13
|
+
// 16px-relative. Returns null only for genuinely unparseable operands.
|
|
11
14
|
const parseSizePx = (v: unknown): number | null => {
|
|
15
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
12
16
|
if (typeof v !== "string") return null;
|
|
13
|
-
|
|
17
|
+
// Real number only — rejects ".", "5.", "1.2.3px" (TASK-034).
|
|
18
|
+
const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
14
19
|
if (!m) return null;
|
|
15
20
|
const n = parseFloat(m[1]);
|
|
21
|
+
if (!Number.isFinite(n)) return null;
|
|
16
22
|
const unit = (m[2] || "px").toLowerCase();
|
|
17
|
-
return unit === "rem" ? n * 16 : n;
|
|
23
|
+
return unit === "rem" || unit === "em" ? n * 16 : n;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export function ThresholdPlugin(rules: ThresholdRule[], ruleId = "threshold"): ConstraintPlugin {
|
|
@@ -24,8 +30,21 @@ export function ThresholdPlugin(rules: ThresholdRule[], ruleId = "threshold"): C
|
|
|
24
30
|
const issues = [];
|
|
25
31
|
for (const r of rules) {
|
|
26
32
|
if (!candidates.has(r.id)) continue; // incremental
|
|
27
|
-
const
|
|
28
|
-
|
|
33
|
+
const raw = engine.get(r.id);
|
|
34
|
+
const px = parseSizePx(raw);
|
|
35
|
+
if (px == null) {
|
|
36
|
+
// Present-but-unparseable size — warn instead of silently skipping (TASK-031).
|
|
37
|
+
if (raw != null && raw !== "") {
|
|
38
|
+
issues.push({
|
|
39
|
+
id: r.id,
|
|
40
|
+
rule: ruleId,
|
|
41
|
+
level: "warn" as const,
|
|
42
|
+
where: r.where,
|
|
43
|
+
message: `Cannot check ${r.id} ${r.op} ${r.valuePx}px: unparseable size value`
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
29
48
|
const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
|
|
30
49
|
if (!ok) {
|
|
31
50
|
issues.push({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wcag.d.ts","sourceRoot":"","sources":["wcag.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,EAAE,EAAE,OAAO,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC7B,CAAC;AASF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,gBAAgB,
|
|
1
|
+
{"version":3,"file":"wcag.d.ts","sourceRoot":"","sources":["wcag.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,EAAE,EAAE,OAAO,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC7B,CAAC;AASF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,gBAAgB,CAuD1E"}
|
package/core/constraints/wcag.js
CHANGED
|
@@ -3,7 +3,7 @@ function resolveColor(engineGet, x) {
|
|
|
3
3
|
if (typeof x !== "string")
|
|
4
4
|
return undefined;
|
|
5
5
|
// If it's a CSS color literal, return as is; else treat as token id.
|
|
6
|
-
const isLiteral =
|
|
6
|
+
const isLiteral = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|transparent$)/i.test(x);
|
|
7
7
|
return isLiteral ? x : String(engineGet(x) ?? "");
|
|
8
8
|
}
|
|
9
9
|
export function WcagContrastPlugin(pairs) {
|
|
@@ -26,6 +26,7 @@ export function WcagContrastPlugin(pairs) {
|
|
|
26
26
|
rule: "wcag-contrast",
|
|
27
27
|
level: "warn",
|
|
28
28
|
where: p.where,
|
|
29
|
+
involvedTokens: [p.fg, p.bg],
|
|
29
30
|
message: `Unparseable color(s): fg="${fgStr}" bg="${bgStr}" backdrop="${backdropStr}"`
|
|
30
31
|
});
|
|
31
32
|
continue;
|
|
@@ -43,6 +44,11 @@ export function WcagContrastPlugin(pairs) {
|
|
|
43
44
|
rule: "wcag-contrast",
|
|
44
45
|
level: "error",
|
|
45
46
|
where: p.where,
|
|
47
|
+
involvedTokens: [p.fg, p.bg],
|
|
48
|
+
metadata: {
|
|
49
|
+
actual: Number(ratio.toFixed(2)),
|
|
50
|
+
required: p.min
|
|
51
|
+
},
|
|
46
52
|
message: `Contrast ${ratio.toFixed(2)}:1 < ${p.min}:1`
|
|
47
53
|
});
|
|
48
54
|
}
|
package/core/constraints/wcag.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type ContrastPair = {
|
|
|
14
14
|
function resolveColor(engineGet: (id: TokenId)=>unknown, x: TokenId | string): string | undefined {
|
|
15
15
|
if (typeof x !== "string") return undefined;
|
|
16
16
|
// If it's a CSS color literal, return as is; else treat as token id.
|
|
17
|
-
const isLiteral =
|
|
17
|
+
const isLiteral = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|transparent$)/i.test(x);
|
|
18
18
|
return isLiteral ? x : String(engineGet(x) ?? "");
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -40,6 +40,7 @@ export function WcagContrastPlugin(pairs: ContrastPair[]): ConstraintPlugin {
|
|
|
40
40
|
rule: "wcag-contrast",
|
|
41
41
|
level: "warn" as const,
|
|
42
42
|
where: p.where,
|
|
43
|
+
involvedTokens: [p.fg, p.bg],
|
|
43
44
|
message: `Unparseable color(s): fg="${fgStr}" bg="${bgStr}" backdrop="${backdropStr}"`
|
|
44
45
|
});
|
|
45
46
|
continue;
|
|
@@ -60,6 +61,11 @@ export function WcagContrastPlugin(pairs: ContrastPair[]): ConstraintPlugin {
|
|
|
60
61
|
rule: "wcag-contrast",
|
|
61
62
|
level: "error" as const,
|
|
62
63
|
where: p.where,
|
|
64
|
+
involvedTokens: [p.fg, p.bg],
|
|
65
|
+
metadata: {
|
|
66
|
+
actual: Number(ratio.toFixed(2)),
|
|
67
|
+
required: p.min
|
|
68
|
+
},
|
|
63
69
|
message: `Contrast ${ratio.toFixed(2)}:1 < ${p.min}:1`
|
|
64
70
|
});
|
|
65
71
|
}
|
package/core/dtcg.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG 2025.10 stable-spec value normalization.
|
|
3
|
+
*
|
|
4
|
+
* The stable spec (2025-10-28) made color and dimension tokens **structured
|
|
5
|
+
* objects** rather than CSS strings — e.g. Figma's native export emits
|
|
6
|
+
* { "colorSpace": "srgb", "components": [0.53, 0.53, 0.53], "alpha": 1, "hex": "#888888" }
|
|
7
|
+
* and
|
|
8
|
+
* { "value": 16, "unit": "px" }.
|
|
9
|
+
*
|
|
10
|
+
* The engine and constraint plugins (WCAG, threshold, monotonic) consume the
|
|
11
|
+
* legacy string/number forms. We normalize structured values to those forms in
|
|
12
|
+
* ONE place — the flatten boundary — so `core/color.ts`'s verified math stays
|
|
13
|
+
* untouched and string aliases (`"{dot.path}"`) keep flowing through unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Non-sRGB color spaces are deliberately NOT coerced into sRGB math: we refuse
|
|
16
|
+
* to treat e.g. display-p3 components as sRGB (that would silently corrupt
|
|
17
|
+
* contrast). Without a `hex` fallback they normalize to a sentinel string that
|
|
18
|
+
* the color parser rejects, producing an explicit "Unparseable color(s)"
|
|
19
|
+
* warning naming the color space — never a wrong-but-silent ratio.
|
|
20
|
+
*/
|
|
21
|
+
export type DtcgColorValue = {
|
|
22
|
+
colorSpace?: string;
|
|
23
|
+
components?: number[];
|
|
24
|
+
alpha?: number;
|
|
25
|
+
hex?: string;
|
|
26
|
+
};
|
|
27
|
+
export type DtcgDimensionValue = {
|
|
28
|
+
value?: number;
|
|
29
|
+
unit?: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a raw DTCG `$value` to the string/number form the engine expects.
|
|
33
|
+
* Strings (including `"{alias}"` references) and numbers pass through untouched.
|
|
34
|
+
* Structured objects are converted; unrecognized objects (composite types such
|
|
35
|
+
* as typography/shadow — out of scope) become a non-crashing sentinel string.
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizeDtcgValue(raw: unknown, type?: string): string | number;
|
|
38
|
+
//# sourceMappingURL=dtcg.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dtcg.d.ts","sourceRoot":"","sources":["dtcg.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAsDF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAU/E"}
|
package/core/dtcg.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG 2025.10 stable-spec value normalization.
|
|
3
|
+
*
|
|
4
|
+
* The stable spec (2025-10-28) made color and dimension tokens **structured
|
|
5
|
+
* objects** rather than CSS strings — e.g. Figma's native export emits
|
|
6
|
+
* { "colorSpace": "srgb", "components": [0.53, 0.53, 0.53], "alpha": 1, "hex": "#888888" }
|
|
7
|
+
* and
|
|
8
|
+
* { "value": 16, "unit": "px" }.
|
|
9
|
+
*
|
|
10
|
+
* The engine and constraint plugins (WCAG, threshold, monotonic) consume the
|
|
11
|
+
* legacy string/number forms. We normalize structured values to those forms in
|
|
12
|
+
* ONE place — the flatten boundary — so `core/color.ts`'s verified math stays
|
|
13
|
+
* untouched and string aliases (`"{dot.path}"`) keep flowing through unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Non-sRGB color spaces are deliberately NOT coerced into sRGB math: we refuse
|
|
16
|
+
* to treat e.g. display-p3 components as sRGB (that would silently corrupt
|
|
17
|
+
* contrast). Without a `hex` fallback they normalize to a sentinel string that
|
|
18
|
+
* the color parser rejects, producing an explicit "Unparseable color(s)"
|
|
19
|
+
* warning naming the color space — never a wrong-but-silent ratio.
|
|
20
|
+
*/
|
|
21
|
+
function toByte(c) {
|
|
22
|
+
return Math.max(0, Math.min(255, Math.round(c * 255)));
|
|
23
|
+
}
|
|
24
|
+
function srgbComponentsToCss(c) {
|
|
25
|
+
if (!Array.isArray(c.components) || c.components.length < 3)
|
|
26
|
+
return null;
|
|
27
|
+
const [r, g, b] = c.components;
|
|
28
|
+
if (![r, g, b].every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
29
|
+
return null;
|
|
30
|
+
const a = typeof c.alpha === 'number' ? c.alpha : 1;
|
|
31
|
+
const R = toByte(r);
|
|
32
|
+
const G = toByte(g);
|
|
33
|
+
const B = toByte(b);
|
|
34
|
+
if (a >= 1) {
|
|
35
|
+
const hx = (n) => n.toString(16).padStart(2, '0');
|
|
36
|
+
return `#${hx(R)}${hx(G)}${hx(B)}`;
|
|
37
|
+
}
|
|
38
|
+
return `rgba(${R}, ${G}, ${B}, ${a})`;
|
|
39
|
+
}
|
|
40
|
+
function normalizeColor(v) {
|
|
41
|
+
// Prefer the spec's `hex` convenience field when present (works for any space).
|
|
42
|
+
if (typeof v.hex === 'string' && v.hex.trim())
|
|
43
|
+
return v.hex.trim();
|
|
44
|
+
// Only gamma-encoded sRGB components map 1:1 to the existing color math.
|
|
45
|
+
if ((v.colorSpace ?? '').toLowerCase() === 'srgb') {
|
|
46
|
+
const css = srgbComponentsToCss(v);
|
|
47
|
+
if (css)
|
|
48
|
+
return css;
|
|
49
|
+
}
|
|
50
|
+
// Unsupported space with no hex fallback → sentinel the parser rejects.
|
|
51
|
+
return `<unsupported colorSpace: ${v.colorSpace ?? 'unknown'}>`;
|
|
52
|
+
}
|
|
53
|
+
function normalizeDimension(v) {
|
|
54
|
+
if (typeof v.value !== 'number' || !Number.isFinite(v.value)) {
|
|
55
|
+
return '<invalid dimension>';
|
|
56
|
+
}
|
|
57
|
+
const unit = typeof v.unit === 'string' && v.unit ? v.unit : 'px';
|
|
58
|
+
return `${v.value}${unit}`;
|
|
59
|
+
}
|
|
60
|
+
function isColorObject(obj, type) {
|
|
61
|
+
return ((type ?? '').toLowerCase() === 'color' ||
|
|
62
|
+
'colorSpace' in obj ||
|
|
63
|
+
'components' in obj ||
|
|
64
|
+
'hex' in obj);
|
|
65
|
+
}
|
|
66
|
+
function isDimensionObject(obj, type) {
|
|
67
|
+
return (type ?? '').toLowerCase() === 'dimension' || ('value' in obj && 'unit' in obj);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a raw DTCG `$value` to the string/number form the engine expects.
|
|
71
|
+
* Strings (including `"{alias}"` references) and numbers pass through untouched.
|
|
72
|
+
* Structured objects are converted; unrecognized objects (composite types such
|
|
73
|
+
* as typography/shadow — out of scope) become a non-crashing sentinel string.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeDtcgValue(raw, type) {
|
|
76
|
+
if (typeof raw === 'string' || typeof raw === 'number')
|
|
77
|
+
return raw;
|
|
78
|
+
if (raw && typeof raw === 'object') {
|
|
79
|
+
const obj = raw;
|
|
80
|
+
if (isColorObject(obj, type))
|
|
81
|
+
return normalizeColor(obj);
|
|
82
|
+
if (isDimensionObject(obj, type))
|
|
83
|
+
return normalizeDimension(obj);
|
|
84
|
+
return `<unsupported $value: ${(type ?? 'object').toLowerCase()}>`;
|
|
85
|
+
}
|
|
86
|
+
// null / undefined / boolean — return an empty string so nothing downstream crashes.
|
|
87
|
+
return '';
|
|
88
|
+
}
|
package/core/dtcg.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DTCG 2025.10 stable-spec value normalization.
|
|
3
|
+
*
|
|
4
|
+
* The stable spec (2025-10-28) made color and dimension tokens **structured
|
|
5
|
+
* objects** rather than CSS strings — e.g. Figma's native export emits
|
|
6
|
+
* { "colorSpace": "srgb", "components": [0.53, 0.53, 0.53], "alpha": 1, "hex": "#888888" }
|
|
7
|
+
* and
|
|
8
|
+
* { "value": 16, "unit": "px" }.
|
|
9
|
+
*
|
|
10
|
+
* The engine and constraint plugins (WCAG, threshold, monotonic) consume the
|
|
11
|
+
* legacy string/number forms. We normalize structured values to those forms in
|
|
12
|
+
* ONE place — the flatten boundary — so `core/color.ts`'s verified math stays
|
|
13
|
+
* untouched and string aliases (`"{dot.path}"`) keep flowing through unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Non-sRGB color spaces are deliberately NOT coerced into sRGB math: we refuse
|
|
16
|
+
* to treat e.g. display-p3 components as sRGB (that would silently corrupt
|
|
17
|
+
* contrast). Without a `hex` fallback they normalize to a sentinel string that
|
|
18
|
+
* the color parser rejects, producing an explicit "Unparseable color(s)"
|
|
19
|
+
* warning naming the color space — never a wrong-but-silent ratio.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type DtcgColorValue = {
|
|
23
|
+
colorSpace?: string;
|
|
24
|
+
components?: number[];
|
|
25
|
+
alpha?: number;
|
|
26
|
+
hex?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type DtcgDimensionValue = {
|
|
30
|
+
value?: number;
|
|
31
|
+
unit?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function toByte(c: number): number {
|
|
35
|
+
return Math.max(0, Math.min(255, Math.round(c * 255)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function srgbComponentsToCss(c: DtcgColorValue): string | null {
|
|
39
|
+
if (!Array.isArray(c.components) || c.components.length < 3) return null;
|
|
40
|
+
const [r, g, b] = c.components;
|
|
41
|
+
if (![r, g, b].every((n) => typeof n === 'number' && Number.isFinite(n))) return null;
|
|
42
|
+
const a = typeof c.alpha === 'number' ? c.alpha : 1;
|
|
43
|
+
const R = toByte(r);
|
|
44
|
+
const G = toByte(g);
|
|
45
|
+
const B = toByte(b);
|
|
46
|
+
if (a >= 1) {
|
|
47
|
+
const hx = (n: number) => n.toString(16).padStart(2, '0');
|
|
48
|
+
return `#${hx(R)}${hx(G)}${hx(B)}`;
|
|
49
|
+
}
|
|
50
|
+
return `rgba(${R}, ${G}, ${B}, ${a})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeColor(v: DtcgColorValue): string {
|
|
54
|
+
// Prefer the spec's `hex` convenience field when present (works for any space).
|
|
55
|
+
if (typeof v.hex === 'string' && v.hex.trim()) return v.hex.trim();
|
|
56
|
+
// Only gamma-encoded sRGB components map 1:1 to the existing color math.
|
|
57
|
+
if ((v.colorSpace ?? '').toLowerCase() === 'srgb') {
|
|
58
|
+
const css = srgbComponentsToCss(v);
|
|
59
|
+
if (css) return css;
|
|
60
|
+
}
|
|
61
|
+
// Unsupported space with no hex fallback → sentinel the parser rejects.
|
|
62
|
+
return `<unsupported colorSpace: ${v.colorSpace ?? 'unknown'}>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeDimension(v: DtcgDimensionValue): string {
|
|
66
|
+
if (typeof v.value !== 'number' || !Number.isFinite(v.value)) {
|
|
67
|
+
return '<invalid dimension>';
|
|
68
|
+
}
|
|
69
|
+
const unit = typeof v.unit === 'string' && v.unit ? v.unit : 'px';
|
|
70
|
+
return `${v.value}${unit}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isColorObject(obj: Record<string, unknown>, type?: string): boolean {
|
|
74
|
+
return (
|
|
75
|
+
(type ?? '').toLowerCase() === 'color' ||
|
|
76
|
+
'colorSpace' in obj ||
|
|
77
|
+
'components' in obj ||
|
|
78
|
+
'hex' in obj
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isDimensionObject(obj: Record<string, unknown>, type?: string): boolean {
|
|
83
|
+
return (type ?? '').toLowerCase() === 'dimension' || ('value' in obj && 'unit' in obj);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize a raw DTCG `$value` to the string/number form the engine expects.
|
|
88
|
+
* Strings (including `"{alias}"` references) and numbers pass through untouched.
|
|
89
|
+
* Structured objects are converted; unrecognized objects (composite types such
|
|
90
|
+
* as typography/shadow — out of scope) become a non-crashing sentinel string.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeDtcgValue(raw: unknown, type?: string): string | number {
|
|
93
|
+
if (typeof raw === 'string' || typeof raw === 'number') return raw;
|
|
94
|
+
if (raw && typeof raw === 'object') {
|
|
95
|
+
const obj = raw as Record<string, unknown>;
|
|
96
|
+
if (isColorObject(obj, type)) return normalizeColor(obj as DtcgColorValue);
|
|
97
|
+
if (isDimensionObject(obj, type)) return normalizeDimension(obj as DtcgDimensionValue);
|
|
98
|
+
return `<unsupported $value: ${(type ?? 'object').toLowerCase()}>`;
|
|
99
|
+
}
|
|
100
|
+
// null / undefined / boolean — return an empty string so nothing downstream crashes.
|
|
101
|
+
return '';
|
|
102
|
+
}
|
package/core/engine.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export type ConstraintIssue = {
|
|
|
26
26
|
* this captures that reference edge.
|
|
27
27
|
*/
|
|
28
28
|
involvedEdges?: Array<[TokenId, TokenId]>;
|
|
29
|
+
/**
|
|
30
|
+
* Structured, rule-specific data for JSON consumers.
|
|
31
|
+
* For example, WCAG contrast issues can expose actual/required ratios here
|
|
32
|
+
* without forcing callers to parse human-readable messages.
|
|
33
|
+
*/
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
29
35
|
};
|
|
30
36
|
/**
|
|
31
37
|
* Constraint plugin interface.
|
package/core/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAExD;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,OAAO,GAAG,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,EAAE,CAAC;IAE3B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,KAAK,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAExD;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,OAAO,GAAG,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,EAAE,CAAC;IAE3B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,KAAK,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAE1C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,eAAe,EAAE,CAAC;CACvE,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;AAE/C,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,OAAO,CAA0B;gBAE7B,UAAU,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IASrF,GAAG,CAAC,MAAM,EAAE,gBAAgB;IAE5B,GAAG,CAAC,EAAE,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS;IACxC,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU;IAElC;;;;;;;OAOG;IACH,SAAS,IAAI,OAAO,EAAE;IAItB;;;;;;;OAOG;IACH,aAAa,IAAI,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC;IAI5C,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IAatC,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,OAAO,CAAC;IAIjC,2FAA2F;IAC3F,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU;;;;;CAQtC"}
|
package/core/engine.ts
CHANGED
|
@@ -29,6 +29,13 @@ export type ConstraintIssue = {
|
|
|
29
29
|
* this captures that reference edge.
|
|
30
30
|
*/
|
|
31
31
|
involvedEdges?: Array<[TokenId, TokenId]>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Structured, rule-specific data for JSON consumers.
|
|
35
|
+
* For example, WCAG contrast issues can expose actual/required ratios here
|
|
36
|
+
* without forcing callers to parse human-readable messages.
|
|
37
|
+
*/
|
|
38
|
+
metadata?: Record<string, unknown>;
|
|
32
39
|
};
|
|
33
40
|
|
|
34
41
|
/**
|
package/core/flatten.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
export type TokenId = string;
|
|
2
2
|
export type TokenValue = string | number;
|
|
3
|
+
export type DtcgStructuredValue = Record<string, unknown>;
|
|
3
4
|
export type TokenNode = {
|
|
4
5
|
$type?: string;
|
|
5
|
-
$value?: TokenValue;
|
|
6
|
-
|
|
6
|
+
$value?: TokenValue | DtcgStructuredValue;
|
|
7
|
+
$extensions?: DtcgStructuredValue;
|
|
8
|
+
[k: string]: TokenNode | string | number | DtcgStructuredValue | undefined;
|
|
7
9
|
};
|
|
8
10
|
export type FlatToken = {
|
|
9
11
|
id: TokenId;
|
|
10
12
|
type: string;
|
|
11
13
|
value: TokenValue;
|
|
12
|
-
raw: TokenValue;
|
|
14
|
+
raw: TokenValue | DtcgStructuredValue;
|
|
13
15
|
refs: TokenId[];
|
|
14
16
|
};
|
|
15
17
|
export type FlattenResult = {
|
package/core/flatten.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"flatten.d.ts","sourceRoot":"","sources":["flatten.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"flatten.d.ts","sourceRoot":"","sources":["flatten.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAC7B,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAGzC,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC1D,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,UAAU,GAAG,mBAAmB,CAAC;IAC1C,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC5E,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,EAAE,UAAU,GAAG,mBAAmB,CAAC;IACtC,IAAI,EAAE,OAAO,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,KAAK,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5C,CAAC;AAKF,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,aAAa,CAqH5D"}
|
package/core/flatten.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
import { normalizeDtcgValue } from "./dtcg.js";
|
|
2
|
+
const REF_RE = /\{([a-z0-9_.-]+)\}/gi;
|
|
3
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2
4
|
export function flattenTokens(root) {
|
|
5
|
+
// Fail closed on a malformed token root (TASK-017). A non-object root — null,
|
|
6
|
+
// an array, or a scalar — previously walked to a silently-empty set that
|
|
7
|
+
// "validated" with ok:true; that hid garbage input. The token root must be a
|
|
8
|
+
// JSON object. (An empty object `{}` is still valid — it just has no tokens.)
|
|
9
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) {
|
|
10
|
+
const got = root === null ? 'null' : Array.isArray(root) ? 'array' : typeof root;
|
|
11
|
+
throw new Error(`Token root must be a JSON object (got ${got}).`);
|
|
12
|
+
}
|
|
3
13
|
const flat = {};
|
|
4
14
|
const edges = [];
|
|
5
15
|
// First pass: collect all tokens
|
|
@@ -8,13 +18,17 @@ export function flattenTokens(root) {
|
|
|
8
18
|
return;
|
|
9
19
|
if (Object.prototype.hasOwnProperty.call(node, '$value')) {
|
|
10
20
|
const id = path.join('.');
|
|
11
|
-
|
|
12
|
-
if (raw === undefined)
|
|
21
|
+
if (node.$value === undefined)
|
|
13
22
|
return; // Skip tokens without values
|
|
23
|
+
// Normalize DTCG 2025.10 structured color/dimension objects to the
|
|
24
|
+
// string/number form the engine + plugins expect (strings, incl. aliases,
|
|
25
|
+
// pass through unchanged). Keeps the color math in core/color.ts untouched.
|
|
26
|
+
const raw = node.$value;
|
|
27
|
+
const normalized = normalizeDtcgValue(raw, node.$type);
|
|
14
28
|
const refs = [];
|
|
15
29
|
// Find all references in the value
|
|
16
|
-
if (typeof
|
|
17
|
-
const matches =
|
|
30
|
+
if (typeof normalized === 'string') {
|
|
31
|
+
const matches = normalized.matchAll(REF_RE);
|
|
18
32
|
for (const match of matches) {
|
|
19
33
|
refs.push(match[1]);
|
|
20
34
|
}
|
|
@@ -22,7 +36,7 @@ export function flattenTokens(root) {
|
|
|
22
36
|
flat[id] = {
|
|
23
37
|
id,
|
|
24
38
|
type: String(node.$type ?? 'unknown'),
|
|
25
|
-
value:
|
|
39
|
+
value: normalized,
|
|
26
40
|
raw,
|
|
27
41
|
refs
|
|
28
42
|
};
|
|
@@ -44,7 +58,7 @@ export function flattenTokens(root) {
|
|
|
44
58
|
// Second pass: resolve references iteratively
|
|
45
59
|
let changed = true;
|
|
46
60
|
let iterations = 0;
|
|
47
|
-
const maxIterations = Object.keys(flat).length * 2; // Safety limit
|
|
61
|
+
const maxIterations = Object.keys(flat).length * 2 + 1; // Safety limit (never 0)
|
|
48
62
|
while (changed && iterations < maxIterations) {
|
|
49
63
|
changed = false;
|
|
50
64
|
iterations++;
|
|
@@ -63,7 +77,7 @@ export function flattenTokens(root) {
|
|
|
63
77
|
break;
|
|
64
78
|
}
|
|
65
79
|
// Replace the reference with the resolved value
|
|
66
|
-
const refPattern = new RegExp(`\\{${refId}\\}`, 'g');
|
|
80
|
+
const refPattern = new RegExp(`\\{${escapeRegExp(refId)}\\}`, 'g');
|
|
67
81
|
newValue = newValue.replace(refPattern, String(refToken.value));
|
|
68
82
|
}
|
|
69
83
|
if (fullyResolved && newValue !== token.value) {
|
|
@@ -73,8 +87,16 @@ export function flattenTokens(root) {
|
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
}
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
// A token still carrying an unresolved "{ref}" placeholder after the fixpoint
|
|
91
|
+
// means a genuine cycle (a -> b -> a) or a self-reference. An empty or
|
|
92
|
+
// fully-literal token set resolves cleanly and must never trip this guard —
|
|
93
|
+
// the old `iterations >= maxIterations` check threw a bogus "circular
|
|
94
|
+
// reference" error whenever no tokens were found (maxIterations === 0).
|
|
95
|
+
const unresolved = Object.values(flat).filter((t) => typeof t.value === 'string' && t.value.includes('{'));
|
|
96
|
+
if (unresolved.length > 0) {
|
|
97
|
+
throw new Error(`Token resolution exceeded maximum iterations - possible circular reference (unresolved: ${unresolved
|
|
98
|
+
.map((t) => t.id)
|
|
99
|
+
.join(', ')})`);
|
|
78
100
|
}
|
|
79
101
|
return { flat, edges };
|
|
80
102
|
}
|