design-constraint-validator 1.0.0 → 1.1.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 (116) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -659
  3. package/adapters/README.md +46 -46
  4. package/adapters/css.ts +116 -116
  5. package/adapters/js.ts +14 -14
  6. package/adapters/json.ts +45 -45
  7. package/cli/build-css.ts +32 -32
  8. package/cli/commands/build.ts +65 -65
  9. package/cli/commands/graph.d.ts.map +1 -1
  10. package/cli/commands/graph.js +26 -10
  11. package/cli/commands/graph.ts +180 -137
  12. package/cli/commands/index.ts +7 -7
  13. package/cli/commands/patch-apply.ts +80 -80
  14. package/cli/commands/patch.ts +22 -22
  15. package/cli/commands/set.d.ts.map +1 -1
  16. package/cli/commands/set.js +12 -4
  17. package/cli/commands/set.ts +239 -225
  18. package/cli/commands/utils.ts +50 -50
  19. package/cli/commands/validate.d.ts.map +1 -1
  20. package/cli/commands/validate.js +86 -33
  21. package/cli/commands/validate.ts +176 -115
  22. package/cli/commands/why.d.ts.map +1 -1
  23. package/cli/commands/why.js +86 -20
  24. package/cli/commands/why.ts +158 -46
  25. package/cli/config-schema.ts +27 -27
  26. package/cli/config.ts +35 -35
  27. package/cli/constraint-registry.d.ts +101 -0
  28. package/cli/constraint-registry.d.ts.map +1 -0
  29. package/cli/constraint-registry.js +225 -0
  30. package/cli/constraint-registry.ts +304 -0
  31. package/cli/constraints-loader.d.ts +30 -0
  32. package/cli/constraints-loader.d.ts.map +1 -0
  33. package/cli/constraints-loader.js +58 -0
  34. package/cli/constraints-loader.ts +83 -0
  35. package/cli/cross-axis-loader.d.ts +91 -0
  36. package/cli/cross-axis-loader.d.ts.map +1 -0
  37. package/cli/cross-axis-loader.js +222 -0
  38. package/cli/cross-axis-loader.ts +289 -0
  39. package/cli/dcv.js +4 -0
  40. package/cli/dcv.ts +111 -107
  41. package/cli/engine-helpers.d.ts +33 -0
  42. package/cli/engine-helpers.d.ts.map +1 -1
  43. package/cli/engine-helpers.js +87 -22
  44. package/cli/engine-helpers.ts +133 -61
  45. package/cli/graph-poset.ts +74 -74
  46. package/cli/json-output.d.ts +64 -0
  47. package/cli/json-output.d.ts.map +1 -0
  48. package/cli/json-output.js +107 -0
  49. package/cli/json-output.ts +177 -0
  50. package/cli/result.ts +27 -27
  51. package/cli/run.ts +54 -54
  52. package/cli/smoke-test.ts +40 -40
  53. package/cli/types.d.ts +6 -0
  54. package/cli/types.d.ts.map +1 -1
  55. package/cli/types.ts +84 -78
  56. package/core/breakpoints.ts +50 -50
  57. package/core/cli-format.ts +31 -31
  58. package/core/color.ts +148 -148
  59. package/core/constraints/cross-axis.ts +114 -114
  60. package/core/constraints/monotonic-lightness.ts +38 -38
  61. package/core/constraints/monotonic.ts +74 -74
  62. package/core/constraints/threshold.ts +43 -43
  63. package/core/constraints/wcag.ts +70 -70
  64. package/core/cross-axis-config.d.ts +29 -0
  65. package/core/cross-axis-config.d.ts.map +1 -1
  66. package/core/cross-axis-config.js +29 -0
  67. package/core/cross-axis-config.ts +181 -151
  68. package/core/engine.d.ts +95 -0
  69. package/core/engine.d.ts.map +1 -1
  70. package/core/engine.js +22 -0
  71. package/core/engine.ts +167 -65
  72. package/core/flatten.ts +116 -116
  73. package/core/image-export.ts +48 -48
  74. package/core/index.d.ts +9 -30
  75. package/core/index.d.ts.map +1 -1
  76. package/core/index.js +7 -54
  77. package/core/index.ts +10 -72
  78. package/core/patch.ts +134 -134
  79. package/core/poset.ts +311 -311
  80. package/core/why.ts +63 -63
  81. package/package.json +96 -90
  82. package/themes/color.lg.order.json +15 -15
  83. package/themes/color.md.order.json +15 -15
  84. package/themes/color.order.json +15 -15
  85. package/themes/color.sm.order.json +15 -15
  86. package/themes/cross-axis.rules.json +35 -35
  87. package/themes/cross-axis.sm.rules.json +12 -12
  88. package/themes/layout.lg.order.json +18 -18
  89. package/themes/layout.md.order.json +18 -18
  90. package/themes/layout.order.json +18 -18
  91. package/themes/layout.sm.order.json +18 -18
  92. package/themes/spacing.order.json +14 -14
  93. package/themes/typography.lg.order.json +15 -15
  94. package/themes/typography.md.order.json +15 -15
  95. package/themes/typography.order.json +15 -15
  96. package/themes/typography.sm.order.json +15 -15
  97. package/dist/test-overrides-removal.json +0 -4
  98. package/dist/tmp.patch.json +0 -35
  99. package/tokens/overrides/base.json +0 -22
  100. package/tokens/overrides/lg.json +0 -20
  101. package/tokens/overrides/md.json +0 -16
  102. package/tokens/overrides/sm.json +0 -16
  103. package/tokens/overrides/viol.color.json +0 -6
  104. package/tokens/overrides/viol.typography.json +0 -6
  105. package/tokens/tokens.demo-violations.json +0 -116
  106. package/tokens/tokens.example.json +0 -128
  107. package/tokens/tokens.json +0 -67
  108. package/tokens/tokens.multi-violations.json +0 -21
  109. package/tokens/tokens.schema.d.ts +0 -2298
  110. package/tokens/tokens.schema.d.ts.map +0 -1
  111. package/tokens/tokens.schema.js +0 -148
  112. package/tokens/tokens.schema.ts +0 -196
  113. package/tokens/tokens.test.json +0 -38
  114. package/tokens/tokens.touch-violation.json +0 -8
  115. package/tokens/typography.classes.css +0 -11
  116. package/tokens/typography.css +0 -20
package/core/color.ts CHANGED
@@ -1,148 +1,148 @@
1
- // Modern CSS color parsing + OKLCH → sRGB, HSL → sRGB, hex/rgb[a].
2
- // Alpha compositing in *linear* light (correct for WCAG).
3
- // All channels are 0..255; alpha 0..1.
4
-
5
- export type RGBA = { r: number; g: number; b: number; a: number };
6
-
7
- const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
8
- const clamp255 = (x: number) => Math.min(255, Math.max(0, x));
9
-
10
- /* ---------- sRGB gamma <-> linear ---------- */
11
- export function srgbToLin(c: number): number {
12
- const cs = c / 255;
13
- return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
14
- }
15
- export function linToSrgb(c: number): number {
16
- return clamp255(
17
- (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255
18
- );
19
- }
20
-
21
- /* ---------- Relative luminance (WCAG 2.x, sRGB) ---------- */
22
- export function relativeLuminance(rgb: RGBA): number {
23
- // assume opaque input; if not, composite first
24
- const R = srgbToLin(rgb.r);
25
- const G = srgbToLin(rgb.g);
26
- const B = srgbToLin(rgb.b);
27
- return 0.2126 * R + 0.7152 * G + 0.0722 * B;
28
- }
29
- export function contrastRatio(L1: number, L2: number): number {
30
- const [a, b] = L1 >= L2 ? [L1, L2] : [L2, L1];
31
- return (a + 0.05) / (b + 0.05);
32
- }
33
-
34
- /* ---------- Parsing ---------- */
35
- const num = (s: string) => parseFloat(s);
36
- const pct = (s: string) => parseFloat(s) / 100;
37
-
38
- export function parseCssColor(input: string | undefined | null): RGBA | null {
39
- if (!input) return null;
40
- const s = input.trim().toLowerCase();
41
-
42
- // hex #rgb/#rgba/#rrggbb/#rrggbbaa
43
- let m = /^#([0-9a-f]{3,4})$/.exec(s);
44
- if (m) {
45
- const h = m[1];
46
- const r = parseInt(h[0] + h[0], 16);
47
- const g = parseInt(h[1] + h[1], 16);
48
- const b = parseInt(h[2] + h[2], 16);
49
- const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
50
- return { r, g, b, a };
51
- }
52
- m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(s);
53
- if (m) {
54
- const n = parseInt(m[1], 16);
55
- const r = (n >> 16) & 255;
56
- const g = (n >> 8) & 255;
57
- const b = n & 255;
58
- const a = m[2] ? parseInt(m[2], 16) / 255 : 1;
59
- return { r, g, b, a };
60
- }
61
-
62
- // rgb/rgba: allow % or 0-255
63
- m = /^rgba?\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,/ )]+)(?:\s*[,/]\s*([^)]+))?\s*\)$/.exec(s);
64
- if (m) {
65
- const ch = (x: string) =>
66
- x.includes("%") ? clamp255(255 * pct(x)) : clamp255(num(x));
67
- const r = ch(m[1]), g = ch(m[2]), b = ch(m[3]);
68
- const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
69
- return { r, g, b, a: clamp01(a) };
70
- }
71
-
72
- // hsl/hsla: h in deg, s/l in %
73
- m = /^hsla?\(\s*([^,]+)\s*,\s*([^,]+)%\s*,\s*([^,/ )]+)%(?:\s*[,/]\s*([^)]+))?\s*\)$/.exec(s);
74
- if (m) {
75
- const H = ((num(m[1]) % 360) + 360) % 360;
76
- const S = clamp01(pct(m[2]));
77
- const L = clamp01(pct(m[3]));
78
- const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
79
- const { r, g, b } = hslToRgb(H, S, L);
80
- return { r, g, b, a: clamp01(a) };
81
- }
82
-
83
- // oklch(L C h / a) — L supports % or 0..1, C in 0..~0.4, h in deg
84
- m = /^oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+([0-9.]+)(?:deg)?(?:\s*[/,]\s*([^)]+))?\s*\)$/.exec(s);
85
- if (m) {
86
- const L = m[1].includes("%") ? clamp01(pct(m[1])) : clamp01(num(m[1]));
87
- const C = Math.max(0, num(m[2]));
88
- const h = ((num(m[3]) % 360) + 360) % 360;
89
- const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
90
- const [r, g, b] = oklchToSrgb(L, C, h).map(v => clamp255(v * 255));
91
- return { r, g, b, a: clamp01(a) };
92
- }
93
-
94
- // named "transparent"
95
- if (s === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
96
-
97
- return null; // extend with more named colors if you want
98
- }
99
-
100
- /* ---------- HSL → sRGB (0..255) ---------- */
101
- function hslToRgb(H: number, S: number, L: number): RGBA {
102
- const C = (1 - Math.abs(2 * L - 1)) * S;
103
- const h = H / 60;
104
- const X = C * (1 - Math.abs((h % 2) - 1));
105
- let r = 0, g = 0, b = 0;
106
- if (0 <= h && h < 1) [r, g, b] = [C, X, 0];
107
- else if (1 <= h && h < 2) [r, g, b] = [X, C, 0];
108
- else if (2 <= h && h < 3) [r, g, b] = [0, C, X];
109
- else if (3 <= h && h < 4) [r, g, b] = [0, X, C];
110
- else if (4 <= h && h < 5) [r, g, b] = [X, 0, C];
111
- else [r, g, b] = [C, 0, X];
112
- const m = L - C / 2;
113
- return { r: clamp255((r + m) * 255), g: clamp255((g + m) * 255), b: clamp255((b + m) * 255), a: 1 };
114
- }
115
-
116
- /* ---------- OKLCH → sRGB (0..1 channels) ---------- */
117
- function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
118
- const h = (hDeg * Math.PI) / 180;
119
- // OKLCH -> OKLab
120
- const a = C * Math.cos(h);
121
- const b = C * Math.sin(h);
122
- // OKLab -> LMS^
123
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
124
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
125
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
126
- const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
127
- // LMS -> linear sRGB
128
- const R = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
129
- const G = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
130
- const B = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
131
- // clamp to [0,1] (gamut clip)
132
- return [clamp01(R), clamp01(G), clamp01(B)];
133
- }
134
-
135
- /* ---------- Alpha compositing (linear light) ---------- */
136
- export function compositeOver(fg: RGBA, bg: RGBA): RGBA {
137
- // Convert to linear, compose, convert back to sRGB
138
- const fr = srgbToLin(fg.r), fgG = srgbToLin(fg.g), fb = srgbToLin(fg.b);
139
- const br = srgbToLin(bg.r), bgG = srgbToLin(bg.g), bb = srgbToLin(bg.b);
140
- const a = fg.a + bg.a * (1 - fg.a);
141
- const rLin = (fr * fg.a + br * bg.a * (1 - fg.a)) / (a || 1);
142
- const gLin = (fgG * fg.a + bgG * bg.a * (1 - fg.a)) / (a || 1);
143
- const bLin = (fb * fg.a + bb * bg.a * (1 - fg.a)) / (a || 1);
144
- return { r: linToSrgb(rLin), g: linToSrgb(gLin), b: linToSrgb(bLin), a: a };
145
- }
146
-
147
- /* ---------- Helpers ---------- */
148
- export function isOpaque(c: RGBA): boolean { return c.a >= 0.999; }
1
+ // Modern CSS color parsing + OKLCH → sRGB, HSL → sRGB, hex/rgb[a].
2
+ // Alpha compositing in *linear* light (correct for WCAG).
3
+ // All channels are 0..255; alpha 0..1.
4
+
5
+ export type RGBA = { r: number; g: number; b: number; a: number };
6
+
7
+ const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
8
+ const clamp255 = (x: number) => Math.min(255, Math.max(0, x));
9
+
10
+ /* ---------- sRGB gamma <-> linear ---------- */
11
+ export function srgbToLin(c: number): number {
12
+ const cs = c / 255;
13
+ return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
14
+ }
15
+ export function linToSrgb(c: number): number {
16
+ return clamp255(
17
+ (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255
18
+ );
19
+ }
20
+
21
+ /* ---------- Relative luminance (WCAG 2.x, sRGB) ---------- */
22
+ export function relativeLuminance(rgb: RGBA): number {
23
+ // assume opaque input; if not, composite first
24
+ const R = srgbToLin(rgb.r);
25
+ const G = srgbToLin(rgb.g);
26
+ const B = srgbToLin(rgb.b);
27
+ return 0.2126 * R + 0.7152 * G + 0.0722 * B;
28
+ }
29
+ export function contrastRatio(L1: number, L2: number): number {
30
+ const [a, b] = L1 >= L2 ? [L1, L2] : [L2, L1];
31
+ return (a + 0.05) / (b + 0.05);
32
+ }
33
+
34
+ /* ---------- Parsing ---------- */
35
+ const num = (s: string) => parseFloat(s);
36
+ const pct = (s: string) => parseFloat(s) / 100;
37
+
38
+ export function parseCssColor(input: string | undefined | null): RGBA | null {
39
+ if (!input) return null;
40
+ const s = input.trim().toLowerCase();
41
+
42
+ // hex #rgb/#rgba/#rrggbb/#rrggbbaa
43
+ let m = /^#([0-9a-f]{3,4})$/.exec(s);
44
+ if (m) {
45
+ const h = m[1];
46
+ const r = parseInt(h[0] + h[0], 16);
47
+ const g = parseInt(h[1] + h[1], 16);
48
+ const b = parseInt(h[2] + h[2], 16);
49
+ const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
50
+ return { r, g, b, a };
51
+ }
52
+ m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(s);
53
+ if (m) {
54
+ const n = parseInt(m[1], 16);
55
+ const r = (n >> 16) & 255;
56
+ const g = (n >> 8) & 255;
57
+ const b = n & 255;
58
+ const a = m[2] ? parseInt(m[2], 16) / 255 : 1;
59
+ return { r, g, b, a };
60
+ }
61
+
62
+ // rgb/rgba: allow % or 0-255
63
+ m = /^rgba?\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,/ )]+)(?:\s*[,/]\s*([^)]+))?\s*\)$/.exec(s);
64
+ if (m) {
65
+ const ch = (x: string) =>
66
+ x.includes("%") ? clamp255(255 * pct(x)) : clamp255(num(x));
67
+ const r = ch(m[1]), g = ch(m[2]), b = ch(m[3]);
68
+ const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
69
+ return { r, g, b, a: clamp01(a) };
70
+ }
71
+
72
+ // hsl/hsla: h in deg, s/l in %
73
+ m = /^hsla?\(\s*([^,]+)\s*,\s*([^,]+)%\s*,\s*([^,/ )]+)%(?:\s*[,/]\s*([^)]+))?\s*\)$/.exec(s);
74
+ if (m) {
75
+ const H = ((num(m[1]) % 360) + 360) % 360;
76
+ const S = clamp01(pct(m[2]));
77
+ const L = clamp01(pct(m[3]));
78
+ const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
79
+ const { r, g, b } = hslToRgb(H, S, L);
80
+ return { r, g, b, a: clamp01(a) };
81
+ }
82
+
83
+ // oklch(L C h / a) — L supports % or 0..1, C in 0..~0.4, h in deg
84
+ m = /^oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+([0-9.]+)(?:deg)?(?:\s*[/,]\s*([^)]+))?\s*\)$/.exec(s);
85
+ if (m) {
86
+ const L = m[1].includes("%") ? clamp01(pct(m[1])) : clamp01(num(m[1]));
87
+ const C = Math.max(0, num(m[2]));
88
+ const h = ((num(m[3]) % 360) + 360) % 360;
89
+ const a = m[4] ? (m[4].includes("%") ? pct(m[4]) : num(m[4])) : 1;
90
+ const [r, g, b] = oklchToSrgb(L, C, h).map(v => clamp255(v * 255));
91
+ return { r, g, b, a: clamp01(a) };
92
+ }
93
+
94
+ // named "transparent"
95
+ if (s === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
96
+
97
+ return null; // extend with more named colors if you want
98
+ }
99
+
100
+ /* ---------- HSL → sRGB (0..255) ---------- */
101
+ function hslToRgb(H: number, S: number, L: number): RGBA {
102
+ const C = (1 - Math.abs(2 * L - 1)) * S;
103
+ const h = H / 60;
104
+ const X = C * (1 - Math.abs((h % 2) - 1));
105
+ let r = 0, g = 0, b = 0;
106
+ if (0 <= h && h < 1) [r, g, b] = [C, X, 0];
107
+ else if (1 <= h && h < 2) [r, g, b] = [X, C, 0];
108
+ else if (2 <= h && h < 3) [r, g, b] = [0, C, X];
109
+ else if (3 <= h && h < 4) [r, g, b] = [0, X, C];
110
+ else if (4 <= h && h < 5) [r, g, b] = [X, 0, C];
111
+ else [r, g, b] = [C, 0, X];
112
+ const m = L - C / 2;
113
+ return { r: clamp255((r + m) * 255), g: clamp255((g + m) * 255), b: clamp255((b + m) * 255), a: 1 };
114
+ }
115
+
116
+ /* ---------- OKLCH → sRGB (0..1 channels) ---------- */
117
+ function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
118
+ const h = (hDeg * Math.PI) / 180;
119
+ // OKLCH -> OKLab
120
+ const a = C * Math.cos(h);
121
+ const b = C * Math.sin(h);
122
+ // OKLab -> LMS^
123
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
124
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
125
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
126
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
127
+ // LMS -> linear sRGB
128
+ const R = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
129
+ const G = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
130
+ const B = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
131
+ // clamp to [0,1] (gamut clip)
132
+ return [clamp01(R), clamp01(G), clamp01(B)];
133
+ }
134
+
135
+ /* ---------- Alpha compositing (linear light) ---------- */
136
+ export function compositeOver(fg: RGBA, bg: RGBA): RGBA {
137
+ // Convert to linear, compose, convert back to sRGB
138
+ const fr = srgbToLin(fg.r), fgG = srgbToLin(fg.g), fb = srgbToLin(fg.b);
139
+ const br = srgbToLin(bg.r), bgG = srgbToLin(bg.g), bb = srgbToLin(bg.b);
140
+ const a = fg.a + bg.a * (1 - fg.a);
141
+ const rLin = (fr * fg.a + br * bg.a * (1 - fg.a)) / (a || 1);
142
+ const gLin = (fgG * fg.a + bgG * bg.a * (1 - fg.a)) / (a || 1);
143
+ const bLin = (fb * fg.a + bb * bg.a * (1 - fg.a)) / (a || 1);
144
+ return { r: linToSrgb(rLin), g: linToSrgb(gLin), b: linToSrgb(bLin), a: a };
145
+ }
146
+
147
+ /* ---------- Helpers ---------- */
148
+ export function isOpaque(c: RGBA): boolean { return c.a >= 0.999; }
@@ -1,114 +1,114 @@
1
- // core/constraints/cross-axis.ts
2
- import type { ConstraintPlugin } from "../engine.js";
3
-
4
- export type CrossAxisRule =
5
- | {
6
- id: string; level?: "error"|"warn"; where?: string;
7
- when: { id: string; test: (v: number) => boolean };
8
- require: { id: string; test: (v: number, ctx: Ctx) => boolean; msg: (v:number, ctx: Ctx)=> string };
9
- }
10
- | {
11
- id: string; level?: "error"|"warn"; where?: string;
12
- contrast: { text: string; bg: string; min: (bp?: string)=> number; ratio: (text:string,bg:string,ctx: Ctx)=> number };
13
- };
14
-
15
- export type Ctx = {
16
- getPx(id: string): number | null;
17
- get(id: string): unknown;
18
- bp?: string;
19
- };
20
-
21
- const px = (v: unknown): number | null => {
22
- if (typeof v === 'number') return v;
23
- if (typeof v !== 'string') return null;
24
- const trimmed = v.trim();
25
- // Direct simple form
26
- let m = trimmed.match(/^([0-9.]+)\s*(px|rem)?$/i);
27
- if (m) {
28
- const n = parseFloat(m[1]);
29
- return (m[2] || 'px').toLowerCase() === 'rem' ? n * 16 : n;
30
- }
31
- // Heuristic: extract first numeric size token (px or rem) inside complex expressions (e.g., clamp())
32
- const inner = trimmed.match(/([0-9.]+)\s*(px|rem)/i);
33
- if (inner) {
34
- const n = parseFloat(inner[1]);
35
- return inner[2].toLowerCase() === 'rem' ? n * 16 : n;
36
- }
37
- return null;
38
- };
39
-
40
- export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): ConstraintPlugin {
41
- return {
42
- id: "cross-axis",
43
- evaluate(engine, candidates) {
44
- const ctx: Ctx = {
45
- getPx: (id) => px(engine.get(id)),
46
- get: (id) => engine.get(id),
47
- bp
48
- };
49
- const issues: Array<{
50
- id: string;
51
- rule: string;
52
- level: "error" | "warn";
53
- where?: string;
54
- message: string;
55
- }> = [];
56
- for (const r of rules) {
57
- if ("when" in r) {
58
- // Evaluate if either referenced id is among candidates (looser gating so global validate works)
59
- if (!candidates.has(r.when.id) && !candidates.has(r.require.id)) continue;
60
- const wv = ctx.getPx(r.when.id);
61
- // compare-style loader rules set when.id = a, require.id = a; use same value if second missing
62
- let rv = ctx.getPx(r.require.id);
63
- if (rv == null && r.require.id === r.when.id) rv = wv;
64
- if (wv == null || rv == null) continue;
65
- if (r.when.test(wv) && !r.require.test(rv, ctx)) {
66
- issues.push({
67
- id: r.require.id,
68
- rule: "cross-axis",
69
- level: r.level ?? "error",
70
- where: r.where,
71
- message: r.require.msg(rv, ctx)
72
- });
73
- }
74
- } else if ("contrast" in r) {
75
- // simple contrast hook; delegate to supplied ratio
76
- if (!candidates.has(r.contrast.text) && !candidates.has(r.contrast.bg)) continue;
77
- const min = r.contrast.min(bp);
78
- const ratio = r.contrast.ratio(r.contrast.text, r.contrast.bg, ctx);
79
- if (ratio < min) {
80
- issues.push({
81
- id: `${r.contrast.text}|${r.contrast.bg}`,
82
- rule: "cross-axis",
83
- level: r.level ?? "warn",
84
- where: r.where,
85
- message: `Contrast ${ratio.toFixed(1)}:1 < ${min}:1`
86
- });
87
- }
88
- }
89
- }
90
- return issues;
91
- }
92
- };
93
- }
94
-
95
- // Helper rule factory: enforce a minimum delta between headings and body size.
96
- export function headingEmphasisRules(heads: string[], bodyId: string, deltaPx = 2): CrossAxisRule[] {
97
- return heads.map(hid => ({
98
- id: `heading-emphasis-${hid}`,
99
- level: 'warn' as const,
100
- where: 'Heading emphasis',
101
- when: { id: bodyId, test: () => true }, // always evaluate
102
- require: {
103
- id: hid,
104
- test: (h: number, ctx) => {
105
- const body = ctx.getPx(bodyId) ?? 16;
106
- return h >= body + deltaPx;
107
- },
108
- msg: (h: number, ctx) => {
109
- const body = ctx.getPx(bodyId) ?? 16;
110
- return `${hid} too close to body: ${h}px < ${body + deltaPx}px`;
111
- }
112
- }
113
- }));
114
- }
1
+ // core/constraints/cross-axis.ts
2
+ import type { ConstraintPlugin } from "../engine.js";
3
+
4
+ export type CrossAxisRule =
5
+ | {
6
+ id: string; level?: "error"|"warn"; where?: string;
7
+ when: { id: string; test: (v: number) => boolean };
8
+ require: { id: string; test: (v: number, ctx: Ctx) => boolean; msg: (v:number, ctx: Ctx)=> string };
9
+ }
10
+ | {
11
+ id: string; level?: "error"|"warn"; where?: string;
12
+ contrast: { text: string; bg: string; min: (bp?: string)=> number; ratio: (text:string,bg:string,ctx: Ctx)=> number };
13
+ };
14
+
15
+ export type Ctx = {
16
+ getPx(id: string): number | null;
17
+ get(id: string): unknown;
18
+ bp?: string;
19
+ };
20
+
21
+ const px = (v: unknown): number | null => {
22
+ if (typeof v === 'number') return v;
23
+ if (typeof v !== 'string') return null;
24
+ const trimmed = v.trim();
25
+ // Direct simple form
26
+ let m = trimmed.match(/^([0-9.]+)\s*(px|rem)?$/i);
27
+ if (m) {
28
+ const n = parseFloat(m[1]);
29
+ return (m[2] || 'px').toLowerCase() === 'rem' ? n * 16 : n;
30
+ }
31
+ // Heuristic: extract first numeric size token (px or rem) inside complex expressions (e.g., clamp())
32
+ const inner = trimmed.match(/([0-9.]+)\s*(px|rem)/i);
33
+ if (inner) {
34
+ const n = parseFloat(inner[1]);
35
+ return inner[2].toLowerCase() === 'rem' ? n * 16 : n;
36
+ }
37
+ return null;
38
+ };
39
+
40
+ export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): ConstraintPlugin {
41
+ return {
42
+ id: "cross-axis",
43
+ evaluate(engine, candidates) {
44
+ const ctx: Ctx = {
45
+ getPx: (id) => px(engine.get(id)),
46
+ get: (id) => engine.get(id),
47
+ bp
48
+ };
49
+ const issues: Array<{
50
+ id: string;
51
+ rule: string;
52
+ level: "error" | "warn";
53
+ where?: string;
54
+ message: string;
55
+ }> = [];
56
+ for (const r of rules) {
57
+ if ("when" in r) {
58
+ // Evaluate if either referenced id is among candidates (looser gating so global validate works)
59
+ if (!candidates.has(r.when.id) && !candidates.has(r.require.id)) continue;
60
+ const wv = ctx.getPx(r.when.id);
61
+ // compare-style loader rules set when.id = a, require.id = a; use same value if second missing
62
+ let rv = ctx.getPx(r.require.id);
63
+ if (rv == null && r.require.id === r.when.id) rv = wv;
64
+ if (wv == null || rv == null) continue;
65
+ if (r.when.test(wv) && !r.require.test(rv, ctx)) {
66
+ issues.push({
67
+ id: r.require.id,
68
+ rule: "cross-axis",
69
+ level: r.level ?? "error",
70
+ where: r.where,
71
+ message: r.require.msg(rv, ctx)
72
+ });
73
+ }
74
+ } else if ("contrast" in r) {
75
+ // simple contrast hook; delegate to supplied ratio
76
+ if (!candidates.has(r.contrast.text) && !candidates.has(r.contrast.bg)) continue;
77
+ const min = r.contrast.min(bp);
78
+ const ratio = r.contrast.ratio(r.contrast.text, r.contrast.bg, ctx);
79
+ if (ratio < min) {
80
+ issues.push({
81
+ id: `${r.contrast.text}|${r.contrast.bg}`,
82
+ rule: "cross-axis",
83
+ level: r.level ?? "warn",
84
+ where: r.where,
85
+ message: `Contrast ${ratio.toFixed(1)}:1 < ${min}:1`
86
+ });
87
+ }
88
+ }
89
+ }
90
+ return issues;
91
+ }
92
+ };
93
+ }
94
+
95
+ // Helper rule factory: enforce a minimum delta between headings and body size.
96
+ export function headingEmphasisRules(heads: string[], bodyId: string, deltaPx = 2): CrossAxisRule[] {
97
+ return heads.map(hid => ({
98
+ id: `heading-emphasis-${hid}`,
99
+ level: 'warn' as const,
100
+ where: 'Heading emphasis',
101
+ when: { id: bodyId, test: () => true }, // always evaluate
102
+ require: {
103
+ id: hid,
104
+ test: (h: number, ctx) => {
105
+ const body = ctx.getPx(bodyId) ?? 16;
106
+ return h >= body + deltaPx;
107
+ },
108
+ msg: (h: number, ctx) => {
109
+ const body = ctx.getPx(bodyId) ?? 16;
110
+ return `${hid} too close to body: ${h}px < ${body + deltaPx}px`;
111
+ }
112
+ }
113
+ }));
114
+ }
@@ -1,38 +1,38 @@
1
- import type { ConstraintPlugin } from "../engine.js";
2
- import { parseCssColor, relativeLuminance } from "../color.js";
3
-
4
- export function parseLightness(v: unknown): number | null {
5
- if (typeof v !== "string") return null;
6
- const s = v.trim().toLowerCase();
7
- const m = /^oklch\(\s*([0-9.]+%?)\s+/.exec(s);
8
- if (m) return m[1].includes("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
9
- const rgba = parseCssColor(s);
10
- return rgba ? relativeLuminance(rgba) : null;
11
- }
12
-
13
- export type Order = [string, "<=" | ">=", string];
14
-
15
- export function MonotonicLightness(orders: Order[]): ConstraintPlugin {
16
- return {
17
- id: "monotonic-lightness",
18
- evaluate(engine, candidates) {
19
- const issues = [];
20
- for (const [a, op, b] of orders) {
21
- if (!candidates.has(a) && !candidates.has(b)) continue;
22
- const La = parseLightness(engine.get(a));
23
- const Lb = parseLightness(engine.get(b));
24
- if (La == null || Lb == null) continue;
25
- const ok = op === ">=" ? La >= Lb : La <= Lb;
26
- if (!ok) {
27
- issues.push({
28
- id: `${a}|${b}`,
29
- rule: "monotonic-lightness",
30
- level: "error" as const,
31
- message: `Lightness order violated: ${a} ${op} ${b} (${La.toFixed(3)} vs ${Lb.toFixed(3)})`
32
- });
33
- }
34
- }
35
- return issues;
36
- }
37
- };
38
- }
1
+ import type { ConstraintPlugin } from "../engine.js";
2
+ import { parseCssColor, relativeLuminance } from "../color.js";
3
+
4
+ export function parseLightness(v: unknown): number | null {
5
+ if (typeof v !== "string") return null;
6
+ const s = v.trim().toLowerCase();
7
+ const m = /^oklch\(\s*([0-9.]+%?)\s+/.exec(s);
8
+ if (m) return m[1].includes("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
9
+ const rgba = parseCssColor(s);
10
+ return rgba ? relativeLuminance(rgba) : null;
11
+ }
12
+
13
+ export type Order = [string, "<=" | ">=", string];
14
+
15
+ export function MonotonicLightness(orders: Order[]): ConstraintPlugin {
16
+ return {
17
+ id: "monotonic-lightness",
18
+ evaluate(engine, candidates) {
19
+ const issues = [];
20
+ for (const [a, op, b] of orders) {
21
+ if (!candidates.has(a) && !candidates.has(b)) continue;
22
+ const La = parseLightness(engine.get(a));
23
+ const Lb = parseLightness(engine.get(b));
24
+ if (La == null || Lb == null) continue;
25
+ const ok = op === ">=" ? La >= Lb : La <= Lb;
26
+ if (!ok) {
27
+ issues.push({
28
+ id: `${a}|${b}`,
29
+ rule: "monotonic-lightness",
30
+ level: "error" as const,
31
+ message: `Lightness order violated: ${a} ${op} ${b} (${La.toFixed(3)} vs ${Lb.toFixed(3)})`
32
+ });
33
+ }
34
+ }
35
+ return issues;
36
+ }
37
+ };
38
+ }