design-constraint-validator 1.0.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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +659 -0
  3. package/adapters/README.md +46 -0
  4. package/adapters/css.d.ts +44 -0
  5. package/adapters/css.d.ts.map +1 -0
  6. package/adapters/css.js +97 -0
  7. package/adapters/css.ts +116 -0
  8. package/adapters/js.d.ts +3 -0
  9. package/adapters/js.d.ts.map +1 -0
  10. package/adapters/js.js +15 -0
  11. package/adapters/js.ts +14 -0
  12. package/adapters/json.d.ts +18 -0
  13. package/adapters/json.d.ts.map +1 -0
  14. package/adapters/json.js +35 -0
  15. package/adapters/json.ts +45 -0
  16. package/cli/build-css.d.ts +2 -0
  17. package/cli/build-css.d.ts.map +1 -0
  18. package/cli/build-css.js +23 -0
  19. package/cli/build-css.ts +32 -0
  20. package/cli/commands/build.d.ts +5 -0
  21. package/cli/commands/build.d.ts.map +1 -0
  22. package/cli/commands/build.js +89 -0
  23. package/cli/commands/build.ts +65 -0
  24. package/cli/commands/graph.d.ts +3 -0
  25. package/cli/commands/graph.d.ts.map +1 -0
  26. package/cli/commands/graph.js +219 -0
  27. package/cli/commands/graph.ts +137 -0
  28. package/cli/commands/index.d.ts +8 -0
  29. package/cli/commands/index.d.ts.map +1 -0
  30. package/cli/commands/index.js +7 -0
  31. package/cli/commands/index.ts +7 -0
  32. package/cli/commands/patch-apply.d.ts +3 -0
  33. package/cli/commands/patch-apply.d.ts.map +1 -0
  34. package/cli/commands/patch-apply.js +75 -0
  35. package/cli/commands/patch-apply.ts +80 -0
  36. package/cli/commands/patch.d.ts +3 -0
  37. package/cli/commands/patch.d.ts.map +1 -0
  38. package/cli/commands/patch.js +21 -0
  39. package/cli/commands/patch.ts +22 -0
  40. package/cli/commands/set.d.ts +3 -0
  41. package/cli/commands/set.d.ts.map +1 -0
  42. package/cli/commands/set.js +286 -0
  43. package/cli/commands/set.ts +225 -0
  44. package/cli/commands/utils.d.ts +4 -0
  45. package/cli/commands/utils.d.ts.map +1 -0
  46. package/cli/commands/utils.js +51 -0
  47. package/cli/commands/utils.ts +50 -0
  48. package/cli/commands/validate.d.ts +3 -0
  49. package/cli/commands/validate.d.ts.map +1 -0
  50. package/cli/commands/validate.js +131 -0
  51. package/cli/commands/validate.ts +115 -0
  52. package/cli/commands/why.d.ts +3 -0
  53. package/cli/commands/why.d.ts.map +1 -0
  54. package/cli/commands/why.js +64 -0
  55. package/cli/commands/why.ts +46 -0
  56. package/cli/config-schema.d.ts +238 -0
  57. package/cli/config-schema.d.ts.map +1 -0
  58. package/cli/config-schema.js +21 -0
  59. package/cli/config-schema.ts +27 -0
  60. package/cli/config.d.ts +4 -0
  61. package/cli/config.d.ts.map +1 -0
  62. package/cli/config.js +37 -0
  63. package/cli/config.ts +35 -0
  64. package/cli/dcv.d.ts +3 -0
  65. package/cli/dcv.d.ts.map +1 -0
  66. package/cli/dcv.js +86 -0
  67. package/cli/dcv.ts +107 -0
  68. package/cli/engine-helpers.d.ts +8 -0
  69. package/cli/engine-helpers.d.ts.map +1 -0
  70. package/cli/engine-helpers.js +70 -0
  71. package/cli/engine-helpers.ts +61 -0
  72. package/cli/graph-poset.d.ts +9 -0
  73. package/cli/graph-poset.d.ts.map +1 -0
  74. package/cli/graph-poset.js +58 -0
  75. package/cli/graph-poset.ts +74 -0
  76. package/cli/index.d.ts +3 -0
  77. package/cli/index.d.ts.map +1 -0
  78. package/cli/index.js +2 -0
  79. package/cli/index.ts +2 -0
  80. package/cli/result.d.ts +17 -0
  81. package/cli/result.d.ts.map +1 -0
  82. package/cli/result.js +29 -0
  83. package/cli/result.ts +27 -0
  84. package/cli/run.d.ts +3 -0
  85. package/cli/run.d.ts.map +1 -0
  86. package/cli/run.js +47 -0
  87. package/cli/run.ts +54 -0
  88. package/cli/smoke-test.d.ts +2 -0
  89. package/cli/smoke-test.d.ts.map +1 -0
  90. package/cli/smoke-test.js +33 -0
  91. package/cli/smoke-test.ts +40 -0
  92. package/cli/types.d.ts +86 -0
  93. package/cli/types.d.ts.map +1 -0
  94. package/cli/types.js +1 -0
  95. package/cli/types.ts +78 -0
  96. package/core/breakpoints.d.ts +12 -0
  97. package/core/breakpoints.d.ts.map +1 -0
  98. package/core/breakpoints.js +48 -0
  99. package/core/breakpoints.ts +50 -0
  100. package/core/cli-format.d.ts +8 -0
  101. package/core/cli-format.d.ts.map +1 -0
  102. package/core/cli-format.js +29 -0
  103. package/core/cli-format.ts +31 -0
  104. package/core/color.d.ts +14 -0
  105. package/core/color.d.ts.map +1 -0
  106. package/core/color.js +136 -0
  107. package/core/color.ts +148 -0
  108. package/core/constraints/cross-axis.d.ts +33 -0
  109. package/core/constraints/cross-axis.d.ts.map +1 -0
  110. package/core/constraints/cross-axis.js +93 -0
  111. package/core/constraints/cross-axis.ts +114 -0
  112. package/core/constraints/monotonic-lightness.d.ts +5 -0
  113. package/core/constraints/monotonic-lightness.d.ts.map +1 -0
  114. package/core/constraints/monotonic-lightness.js +37 -0
  115. package/core/constraints/monotonic-lightness.ts +38 -0
  116. package/core/constraints/monotonic.d.ts +7 -0
  117. package/core/constraints/monotonic.d.ts.map +1 -0
  118. package/core/constraints/monotonic.js +65 -0
  119. package/core/constraints/monotonic.ts +74 -0
  120. package/core/constraints/threshold.d.ts +10 -0
  121. package/core/constraints/threshold.d.ts.map +1 -0
  122. package/core/constraints/threshold.js +36 -0
  123. package/core/constraints/threshold.ts +43 -0
  124. package/core/constraints/wcag.d.ts +11 -0
  125. package/core/constraints/wcag.d.ts.map +1 -0
  126. package/core/constraints/wcag.js +53 -0
  127. package/core/constraints/wcag.ts +70 -0
  128. package/core/cross-axis-config.d.ts +5 -0
  129. package/core/cross-axis-config.d.ts.map +1 -0
  130. package/core/cross-axis-config.js +144 -0
  131. package/core/cross-axis-config.ts +152 -0
  132. package/core/engine.d.ts +32 -0
  133. package/core/engine.d.ts.map +1 -0
  134. package/core/engine.js +46 -0
  135. package/core/engine.ts +65 -0
  136. package/core/flatten.d.ts +20 -0
  137. package/core/flatten.d.ts.map +1 -0
  138. package/core/flatten.js +80 -0
  139. package/core/flatten.ts +116 -0
  140. package/core/image-export.d.ts +10 -0
  141. package/core/image-export.d.ts.map +1 -0
  142. package/core/image-export.js +43 -0
  143. package/core/image-export.ts +48 -0
  144. package/core/index.d.ts +31 -0
  145. package/core/index.d.ts.map +1 -0
  146. package/core/index.js +54 -0
  147. package/core/index.ts +72 -0
  148. package/core/patch.d.ts +28 -0
  149. package/core/patch.d.ts.map +1 -0
  150. package/core/patch.js +110 -0
  151. package/core/patch.ts +134 -0
  152. package/core/poset.d.ts +41 -0
  153. package/core/poset.d.ts.map +1 -0
  154. package/core/poset.js +275 -0
  155. package/core/poset.ts +311 -0
  156. package/core/why.d.ts +17 -0
  157. package/core/why.d.ts.map +1 -0
  158. package/core/why.js +45 -0
  159. package/core/why.ts +63 -0
  160. package/dist/test-overrides-removal.json +4 -0
  161. package/dist/tmp.patch.json +35 -0
  162. package/package.json +90 -0
  163. package/themes/color.lg.order.json +15 -0
  164. package/themes/color.md.order.json +15 -0
  165. package/themes/color.order.json +15 -0
  166. package/themes/color.sm.order.json +15 -0
  167. package/themes/cross-axis.rules.json +36 -0
  168. package/themes/cross-axis.sm.rules.json +12 -0
  169. package/themes/layout.lg.order.json +18 -0
  170. package/themes/layout.md.order.json +18 -0
  171. package/themes/layout.order.json +18 -0
  172. package/themes/layout.sm.order.json +18 -0
  173. package/themes/spacing.order.json +14 -0
  174. package/themes/typography.lg.order.json +15 -0
  175. package/themes/typography.md.order.json +15 -0
  176. package/themes/typography.order.json +15 -0
  177. package/themes/typography.sm.order.json +15 -0
  178. package/tokens/overrides/base.json +22 -0
  179. package/tokens/overrides/lg.json +20 -0
  180. package/tokens/overrides/md.json +16 -0
  181. package/tokens/overrides/sm.json +16 -0
  182. package/tokens/overrides/viol.color.json +6 -0
  183. package/tokens/overrides/viol.typography.json +6 -0
  184. package/tokens/tokens.demo-violations.json +116 -0
  185. package/tokens/tokens.example.json +128 -0
  186. package/tokens/tokens.json +67 -0
  187. package/tokens/tokens.multi-violations.json +21 -0
  188. package/tokens/tokens.schema.d.ts +2298 -0
  189. package/tokens/tokens.schema.d.ts.map +1 -0
  190. package/tokens/tokens.schema.js +148 -0
  191. package/tokens/tokens.schema.ts +196 -0
  192. package/tokens/tokens.test.json +38 -0
  193. package/tokens/tokens.touch-violation.json +8 -0
  194. package/tokens/typography.classes.css +11 -0
  195. package/tokens/typography.css +20 -0
@@ -0,0 +1,65 @@
1
+ export function MonotonicPlugin(orders, parse, ruleId = "monotonic") {
2
+ return {
3
+ id: ruleId,
4
+ evaluate(engine, candidates) {
5
+ const issues = [];
6
+ for (const [a, op, b] of orders) {
7
+ const va = parse(engine.get(a));
8
+ const vb = parse(engine.get(b));
9
+ if (va == null || vb == null)
10
+ continue; // skip unparseable
11
+ const ok = op === ">=" ? va >= vb : va <= vb;
12
+ if (!ok && (candidates.has(a) || candidates.has(b))) {
13
+ issues.push({
14
+ id: `${a}|${b}`,
15
+ rule: "monotonic",
16
+ level: "error",
17
+ message: `${a} ${op} ${b} violated: ${va} vs ${vb}`
18
+ });
19
+ }
20
+ }
21
+ return issues;
22
+ }
23
+ };
24
+ }
25
+ // a minimal parser for "rem"/"px" numbers
26
+ export const parseSize = (v) => {
27
+ if (typeof v !== "string")
28
+ return null;
29
+ const m = v.trim().match(/^([0-9.]+)(rem|px)?$/i);
30
+ if (!m)
31
+ return null;
32
+ const num = parseFloat(m[1]);
33
+ const unit = (m[2] || "rem").toLowerCase();
34
+ return unit === "px" ? num : num * 16; // assume 1rem=16px for comparisons
35
+ };
36
+ // Parser for unitless numbers (like scale factors)
37
+ export const parseNumber = (v) => {
38
+ if (typeof v === "number")
39
+ return v;
40
+ if (typeof v === "string") {
41
+ const num = parseFloat(v.trim());
42
+ return isNaN(num) ? null : num;
43
+ }
44
+ return null;
45
+ };
46
+ // Parser for color lightness (OKLCH L channel)
47
+ export const parseLightness = (v) => {
48
+ if (typeof v !== "string")
49
+ return null;
50
+ // Match oklch(L C H / A) format
51
+ const oklchMatch = v.trim().match(/oklch\s*\(\s*([0-9.]+)\s+/i);
52
+ if (oklchMatch) {
53
+ return parseFloat(oklchMatch[1]);
54
+ }
55
+ // For hex colors, rough approximation (you'd want a proper converter)
56
+ const hexMatch = v.trim().match(/^#([0-9a-f]{6})$/i);
57
+ if (hexMatch) {
58
+ const r = parseInt(hexMatch[1].slice(0, 2), 16) / 255;
59
+ const g = parseInt(hexMatch[1].slice(2, 4), 16) / 255;
60
+ const b = parseInt(hexMatch[1].slice(4, 6), 16) / 255;
61
+ // Simple luminance approximation
62
+ return 0.299 * r + 0.587 * g + 0.114 * b;
63
+ }
64
+ return null;
65
+ };
@@ -0,0 +1,74 @@
1
+ // core/constraints/monotonic.ts
2
+ import type { ConstraintPlugin } from "../engine.js";
3
+ import type { Order } from "../poset.js";
4
+
5
+ export function MonotonicPlugin(
6
+ orders: Order[],
7
+ parse: (v: unknown) => number | null,
8
+ ruleId = "monotonic"
9
+ ): ConstraintPlugin {
10
+ return {
11
+ id: ruleId,
12
+ evaluate(engine, candidates) {
13
+ const issues = [];
14
+ for (const [a, op, b] of orders) {
15
+ const va = parse(engine.get(a));
16
+ const vb = parse(engine.get(b));
17
+ if (va == null || vb == null) continue; // skip unparseable
18
+ const ok = op === ">=" ? va >= vb : va <= vb;
19
+ if (!ok && (candidates.has(a) || candidates.has(b))) {
20
+ issues.push({
21
+ id: `${a}|${b}`,
22
+ rule: "monotonic",
23
+ level: "error" as const,
24
+ message: `${a} ${op} ${b} violated: ${va} vs ${vb}`
25
+ });
26
+ }
27
+ }
28
+ return issues;
29
+ }
30
+ };
31
+ }
32
+
33
+ // a minimal parser for "rem"/"px" numbers
34
+ export const parseSize = (v: unknown): number | null => {
35
+ if (typeof v !== "string") return null;
36
+ const m = v.trim().match(/^([0-9.]+)(rem|px)?$/i);
37
+ if (!m) return null;
38
+ const num = parseFloat(m[1]);
39
+ const unit = (m[2] || "rem").toLowerCase();
40
+ return unit === "px" ? num : num * 16; // assume 1rem=16px for comparisons
41
+ };
42
+
43
+ // Parser for unitless numbers (like scale factors)
44
+ export const parseNumber = (v: unknown): number | null => {
45
+ if (typeof v === "number") return v;
46
+ if (typeof v === "string") {
47
+ const num = parseFloat(v.trim());
48
+ return isNaN(num) ? null : num;
49
+ }
50
+ return null;
51
+ };
52
+
53
+ // Parser for color lightness (OKLCH L channel)
54
+ export const parseLightness = (v: unknown): number | null => {
55
+ if (typeof v !== "string") return null;
56
+
57
+ // Match oklch(L C H / A) format
58
+ const oklchMatch = v.trim().match(/oklch\s*\(\s*([0-9.]+)\s+/i);
59
+ if (oklchMatch) {
60
+ return parseFloat(oklchMatch[1]);
61
+ }
62
+
63
+ // For hex colors, rough approximation (you'd want a proper converter)
64
+ const hexMatch = v.trim().match(/^#([0-9a-f]{6})$/i);
65
+ if (hexMatch) {
66
+ const r = parseInt(hexMatch[1].slice(0, 2), 16) / 255;
67
+ const g = parseInt(hexMatch[1].slice(2, 4), 16) / 255;
68
+ const b = parseInt(hexMatch[1].slice(4, 6), 16) / 255;
69
+ // Simple luminance approximation
70
+ return 0.299 * r + 0.587 * g + 0.114 * b;
71
+ }
72
+
73
+ return null;
74
+ };
@@ -0,0 +1,10 @@
1
+ import type { ConstraintPlugin } from "../engine.js";
2
+ export type ThresholdRule = {
3
+ id: string;
4
+ op: ">=" | "<=";
5
+ valuePx: number;
6
+ where?: string;
7
+ level?: "error" | "warn";
8
+ };
9
+ export declare function ThresholdPlugin(rules: ThresholdRule[], ruleId?: string): ConstraintPlugin;
10
+ //# sourceMappingURL=threshold.d.ts.map
@@ -0,0 +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;AAWF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,SAAc,GAAG,gBAAgB,CAuB9F"}
@@ -0,0 +1,36 @@
1
+ const parseSizePx = (v) => {
2
+ if (typeof v !== "string")
3
+ return null;
4
+ const m = v.trim().match(/^([0-9.]+)\s*(px|rem)?$/i);
5
+ if (!m)
6
+ return null;
7
+ const n = parseFloat(m[1]);
8
+ const unit = (m[2] || "px").toLowerCase();
9
+ return unit === "rem" ? n * 16 : n;
10
+ };
11
+ export function ThresholdPlugin(rules, ruleId = "threshold") {
12
+ return {
13
+ id: ruleId,
14
+ evaluate(engine, candidates) {
15
+ const issues = [];
16
+ for (const r of rules) {
17
+ if (!candidates.has(r.id))
18
+ continue; // incremental
19
+ const px = parseSizePx(engine.get(r.id));
20
+ if (px == null)
21
+ continue;
22
+ const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
23
+ if (!ok) {
24
+ issues.push({
25
+ id: r.id,
26
+ rule: ruleId,
27
+ level: r.level ?? "error",
28
+ where: r.where,
29
+ message: `${r.id} ${r.op} ${r.valuePx}px violated: ${px}px`
30
+ });
31
+ }
32
+ }
33
+ return issues;
34
+ }
35
+ };
36
+ }
@@ -0,0 +1,43 @@
1
+ import type { ConstraintPlugin } from "../engine.js";
2
+
3
+ export type ThresholdRule = {
4
+ id: string; // token id to check
5
+ op: ">=" | "<="; // comparison
6
+ valuePx: number; // threshold in px
7
+ where?: string; // optional context label
8
+ level?: "error" | "warn"; // default error
9
+ };
10
+
11
+ const parseSizePx = (v: unknown): number | null => {
12
+ if (typeof v !== "string") return null;
13
+ const m = v.trim().match(/^([0-9.]+)\s*(px|rem)?$/i);
14
+ if (!m) return null;
15
+ const n = parseFloat(m[1]);
16
+ const unit = (m[2] || "px").toLowerCase();
17
+ return unit === "rem" ? n * 16 : n;
18
+ };
19
+
20
+ export function ThresholdPlugin(rules: ThresholdRule[], ruleId = "threshold"): ConstraintPlugin {
21
+ return {
22
+ id: ruleId,
23
+ evaluate(engine, candidates) {
24
+ const issues = [];
25
+ for (const r of rules) {
26
+ if (!candidates.has(r.id)) continue; // incremental
27
+ const px = parseSizePx(engine.get(r.id));
28
+ if (px == null) continue;
29
+ const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
30
+ if (!ok) {
31
+ issues.push({
32
+ id: r.id,
33
+ rule: ruleId,
34
+ level: r.level ?? "error",
35
+ where: r.where,
36
+ message: `${r.id} ${r.op} ${r.valuePx}px violated: ${px}px`
37
+ });
38
+ }
39
+ }
40
+ return issues;
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConstraintPlugin } from "../engine.js";
2
+ import type { TokenId } from "../flatten.js";
3
+ export type ContrastPair = {
4
+ fg: TokenId;
5
+ bg: TokenId;
6
+ min: number;
7
+ where?: string;
8
+ backdrop?: TokenId | string;
9
+ };
10
+ export declare function WcagContrastPlugin(pairs: ContrastPair[]): ConstraintPlugin;
11
+ //# sourceMappingURL=wcag.d.ts.map
@@ -0,0 +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,CAiD1E"}
@@ -0,0 +1,53 @@
1
+ import { parseCssColor, compositeOver, relativeLuminance, contrastRatio } from "../color.js";
2
+ function resolveColor(engineGet, x) {
3
+ if (typeof x !== "string")
4
+ return undefined;
5
+ // If it's a CSS color literal, return as is; else treat as token id.
6
+ const isLiteral = /^#|^rgb|^hsl|^oklch|^oklab|^transparent/i.test(x);
7
+ return isLiteral ? x : String(engineGet(x) ?? "");
8
+ }
9
+ export function WcagContrastPlugin(pairs) {
10
+ return {
11
+ id: "wcag-contrast",
12
+ evaluate(engine, candidates) {
13
+ const issues = [];
14
+ for (const p of pairs) {
15
+ if (!candidates.has(p.fg) && !candidates.has(p.bg))
16
+ continue;
17
+ const fgStr = String(engine.get(p.fg) ?? "");
18
+ const bgStr = String(engine.get(p.bg) ?? "");
19
+ const fg = parseCssColor(fgStr);
20
+ const bgRaw = parseCssColor(bgStr);
21
+ const backdropStr = resolveColor(engine.get.bind(engine), p.backdrop ?? "#ffffff");
22
+ const backdrop = parseCssColor(backdropStr || "#ffffff");
23
+ if (!fg || !bgRaw || !backdrop) {
24
+ issues.push({
25
+ id: `${p.fg}|${p.bg}`,
26
+ rule: "wcag-contrast",
27
+ level: "warn",
28
+ where: p.where,
29
+ message: `Unparseable color(s): fg="${fgStr}" bg="${bgStr}" backdrop="${backdropStr}"`
30
+ });
31
+ continue;
32
+ }
33
+ // Effective background (handle bg alpha)
34
+ const effBg = bgRaw.a < 1 ? compositeOver(bgRaw, backdrop) : bgRaw;
35
+ // Effective foreground over effective background
36
+ const effFg = fg.a < 1 ? compositeOver(fg, effBg) : fg;
37
+ const Lfg = relativeLuminance(effFg);
38
+ const Lbg = relativeLuminance(effBg);
39
+ const ratio = contrastRatio(Lfg, Lbg);
40
+ if (ratio < p.min) {
41
+ issues.push({
42
+ id: `${p.fg}|${p.bg}`,
43
+ rule: "wcag-contrast",
44
+ level: "error",
45
+ where: p.where,
46
+ message: `Contrast ${ratio.toFixed(2)}:1 < ${p.min}:1`
47
+ });
48
+ }
49
+ }
50
+ return issues;
51
+ }
52
+ };
53
+ }
@@ -0,0 +1,70 @@
1
+ import type { ConstraintPlugin } from "../engine.js";
2
+ import type { TokenId } from "../flatten.js";
3
+ import { parseCssColor, compositeOver, relativeLuminance, contrastRatio } from "../color.js";
4
+
5
+ export type ContrastPair = {
6
+ fg: TokenId;
7
+ bg: TokenId;
8
+ min: number; // e.g., 4.5
9
+ where?: string;
10
+ // Optional ultimate backdrop if bg is transparent; token id or literal color.
11
+ backdrop?: TokenId | string; // default: "#ffffff"
12
+ };
13
+
14
+ function resolveColor(engineGet: (id: TokenId)=>unknown, x: TokenId | string): string | undefined {
15
+ if (typeof x !== "string") return undefined;
16
+ // If it's a CSS color literal, return as is; else treat as token id.
17
+ const isLiteral = /^#|^rgb|^hsl|^oklch|^oklab|^transparent/i.test(x);
18
+ return isLiteral ? x : String(engineGet(x) ?? "");
19
+ }
20
+
21
+ export function WcagContrastPlugin(pairs: ContrastPair[]): ConstraintPlugin {
22
+ return {
23
+ id: "wcag-contrast",
24
+ evaluate(engine, candidates) {
25
+ const issues = [];
26
+ for (const p of pairs) {
27
+ if (!candidates.has(p.fg) && !candidates.has(p.bg)) continue;
28
+
29
+ const fgStr = String(engine.get(p.fg) ?? "");
30
+ const bgStr = String(engine.get(p.bg) ?? "");
31
+ const fg = parseCssColor(fgStr);
32
+ const bgRaw = parseCssColor(bgStr);
33
+
34
+ const backdropStr = resolveColor(engine.get.bind(engine), p.backdrop ?? "#ffffff");
35
+ const backdrop = parseCssColor(backdropStr || "#ffffff");
36
+
37
+ if (!fg || !bgRaw || !backdrop) {
38
+ issues.push({
39
+ id: `${p.fg}|${p.bg}`,
40
+ rule: "wcag-contrast",
41
+ level: "warn" as const,
42
+ where: p.where,
43
+ message: `Unparseable color(s): fg="${fgStr}" bg="${bgStr}" backdrop="${backdropStr}"`
44
+ });
45
+ continue;
46
+ }
47
+
48
+ // Effective background (handle bg alpha)
49
+ const effBg = bgRaw.a < 1 ? compositeOver(bgRaw, backdrop) : bgRaw;
50
+ // Effective foreground over effective background
51
+ const effFg = fg.a < 1 ? compositeOver(fg, effBg) : fg;
52
+
53
+ const Lfg = relativeLuminance(effFg);
54
+ const Lbg = relativeLuminance(effBg);
55
+ const ratio = contrastRatio(Lfg, Lbg);
56
+
57
+ if (ratio < p.min) {
58
+ issues.push({
59
+ id: `${p.fg}|${p.bg}`,
60
+ rule: "wcag-contrast",
61
+ level: "error" as const,
62
+ where: p.where,
63
+ message: `Contrast ${ratio.toFixed(2)}:1 < ${p.min}:1`
64
+ });
65
+ }
66
+ }
67
+ return issues;
68
+ }
69
+ };
70
+ }
@@ -0,0 +1,5 @@
1
+ export declare function loadCrossAxisPlugin(path: string, bp?: string, opts?: {
2
+ debug?: boolean;
3
+ knownIds?: Set<string>;
4
+ }): import("./engine.js").ConstraintPlugin;
5
+ //# sourceMappingURL=cross-axis-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-axis-config.d.ts","sourceRoot":"","sources":["cross-axis-config.ts"],"names":[],"mappings":"AAQA,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,EAAE,CAAC,EAAE,MAAM,EACX,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,0CA2HnD"}
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs";
2
+ import { CrossAxisPlugin } from "./constraints/cross-axis.js";
3
+ export function loadCrossAxisPlugin(path, bp, opts) {
4
+ const debug = !!opts?.debug;
5
+ const known = opts?.knownIds ?? new Set();
6
+ const log = (...args) => { if (debug)
7
+ console.log("[cross-axis]", ...args); };
8
+ if (!fs.existsSync(path)) {
9
+ log(`no rules file at ${path} (bp=${bp ?? "global"})`);
10
+ return CrossAxisPlugin([], bp);
11
+ }
12
+ const raw = JSON.parse(fs.readFileSync(path, "utf8"));
13
+ const rules = [];
14
+ const unknownIds = new Set();
15
+ const skipped = [];
16
+ // Fuzzy suggestion helpers (lightweight Levenshtein)
17
+ function levenshtein(a, b) {
18
+ const dp = Array(b.length + 1).fill(0).map((_, j) => j);
19
+ for (let i = 1; i <= a.length; i++) {
20
+ let prev = i - 1, cur = i;
21
+ for (let j = 1; j <= b.length; j++) {
22
+ const tmp = cur;
23
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
24
+ cur = Math.min(dp[j] + 1, cur + 1, prev + cost);
25
+ dp[j] = tmp;
26
+ prev = tmp;
27
+ }
28
+ dp[b.length] = cur;
29
+ }
30
+ return dp[b.length];
31
+ }
32
+ function suggest(id, k = 3) {
33
+ return [...known].map(c => ({ id: c, d: levenshtein(id, c) }))
34
+ .sort((a, b) => a.d - b.d)
35
+ .slice(0, k);
36
+ }
37
+ const needId = (id) => {
38
+ if (!id)
39
+ return false;
40
+ if (!known.has(id)) {
41
+ unknownIds.add(id);
42
+ }
43
+ return true;
44
+ };
45
+ for (const r of raw.rules || []) {
46
+ if (r.bp && bp && r.bp !== bp) {
47
+ continue;
48
+ }
49
+ if (r.bp && !bp) { // rule targets specific breakpoint; skip in global run
50
+ continue;
51
+ }
52
+ try {
53
+ if (r.when && r.require) {
54
+ // Validate IDs
55
+ needId(r.when.id);
56
+ needId(r.require.id);
57
+ if (r.require.ref)
58
+ needId(r.require.ref);
59
+ rules.push({
60
+ id: r.id, level: r.level, where: r.where,
61
+ when: { id: r.when.id, test: makeOp(r.when.op, r.when.value) },
62
+ require: {
63
+ id: r.require.id,
64
+ test: (v, ctx) => {
65
+ const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
66
+ return cmp(v, rhs, r.require.op);
67
+ },
68
+ msg: (v, ctx) => {
69
+ const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
70
+ return `${r.require.id} ${prettyFail(r.require.op)} ${fmt(rhs)} (was ${fmt(v)})`;
71
+ }
72
+ }
73
+ });
74
+ }
75
+ else if (r.compare) {
76
+ needId(r.compare.a);
77
+ needId(r.compare.b);
78
+ rules.push({
79
+ id: r.id, level: r.level, where: r.where,
80
+ when: { id: r.compare.a, test: () => true },
81
+ require: {
82
+ id: r.compare.a,
83
+ test: (_, ctx) => {
84
+ const a = ctx.getPx(r.compare.a) ?? NaN;
85
+ const b = ctx.getPx(r.compare.b) ?? NaN;
86
+ const delta = px(r.compare.delta ?? 0);
87
+ if (Number.isNaN(a) || Number.isNaN(b))
88
+ return true; // skip check if missing
89
+ return cmp(a, b + delta, r.compare.op);
90
+ },
91
+ msg: (_, ctx) => {
92
+ const a = ctx.getPx(r.compare.a);
93
+ const b = ctx.getPx(r.compare.b);
94
+ const delta = px(r.compare.delta ?? 0);
95
+ return `${r.compare.a} ${prettyFail(r.compare.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
96
+ }
97
+ }
98
+ });
99
+ }
100
+ else {
101
+ skipped.push({ id: r.id, reason: "neither when+require nor compare present" });
102
+ }
103
+ }
104
+ catch (e) {
105
+ skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
106
+ }
107
+ }
108
+ log(`loaded ${rules.length} rule(s) from ${path}${bp ? ` [bp=${bp}]` : ""}`);
109
+ if (unknownIds.size) {
110
+ log(`unknown ids referenced:`, [...unknownIds].join(", "));
111
+ for (const u of unknownIds) {
112
+ const s = suggest(u, 3);
113
+ if (s.length)
114
+ log(` did you mean: ${s.map(x => `${x.id} (d=${x.d})`).join(', ')}`);
115
+ }
116
+ }
117
+ if (skipped.length) {
118
+ for (const s of skipped)
119
+ log(`skipped rule ${s.id ?? "(no id)"} — ${s.reason}`);
120
+ }
121
+ // Extra hint for common anchor pitfall
122
+ for (const r of raw.rules || []) {
123
+ if (r.require?.ref && !known.has(r.require.ref)) {
124
+ log(`anchor missing: ${r.require.ref} → will use fallback=${JSON.stringify(r.require.fallback)} when evaluating`);
125
+ }
126
+ }
127
+ return CrossAxisPlugin(rules, bp);
128
+ }
129
+ // helpers
130
+ const px = (v) => typeof v === "number" ? v : parseFloat(String(v)) * (String(v).trim().endsWith("rem") ? 16 : 1);
131
+ const cmp = (a, b, op) => op === ">=" ? a >= b : op === ">" ? a > b : op === "<=" ? a <= b : op === "<" ? a < b : op === "==" ? a === b : a !== b;
132
+ const prettyFail = (op) => ({ ">=": "<", ">": "≤", "<=": ">", "<": "≥", "==": "≠", "!=": "=" }[op] || "≠");
133
+ const fmt = (v) => Number.isFinite(Number(v)) ? `${Number(v)}px` : String(v);
134
+ function valueOrRef(ctx, ref, fallback) {
135
+ if (ref) {
136
+ const v = ctx.getPx(ref);
137
+ if (v != null)
138
+ return v;
139
+ }
140
+ return typeof fallback === "number" ? fallback : px(fallback ?? 0);
141
+ }
142
+ function makeOp(op, rhs) {
143
+ return (v) => cmp(v, rhs, op);
144
+ }