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.
- package/LICENSE +21 -21
- package/README.md +215 -659
- package/adapters/README.md +46 -46
- package/adapters/css.ts +116 -116
- package/adapters/js.ts +14 -14
- package/adapters/json.ts +45 -45
- package/cli/build-css.ts +32 -32
- package/cli/commands/build.ts +65 -65
- package/cli/commands/graph.d.ts.map +1 -1
- package/cli/commands/graph.js +26 -10
- package/cli/commands/graph.ts +180 -137
- package/cli/commands/index.ts +7 -7
- package/cli/commands/patch-apply.ts +80 -80
- package/cli/commands/patch.ts +22 -22
- package/cli/commands/set.d.ts.map +1 -1
- package/cli/commands/set.js +12 -4
- package/cli/commands/set.ts +239 -225
- package/cli/commands/utils.ts +50 -50
- package/cli/commands/validate.d.ts.map +1 -1
- package/cli/commands/validate.js +86 -33
- package/cli/commands/validate.ts +176 -115
- package/cli/commands/why.d.ts.map +1 -1
- package/cli/commands/why.js +86 -20
- package/cli/commands/why.ts +158 -46
- package/cli/config-schema.ts +27 -27
- package/cli/config.ts +35 -35
- package/cli/constraint-registry.d.ts +101 -0
- package/cli/constraint-registry.d.ts.map +1 -0
- package/cli/constraint-registry.js +225 -0
- package/cli/constraint-registry.ts +304 -0
- package/cli/constraints-loader.d.ts +30 -0
- package/cli/constraints-loader.d.ts.map +1 -0
- package/cli/constraints-loader.js +58 -0
- package/cli/constraints-loader.ts +83 -0
- package/cli/cross-axis-loader.d.ts +91 -0
- package/cli/cross-axis-loader.d.ts.map +1 -0
- package/cli/cross-axis-loader.js +222 -0
- package/cli/cross-axis-loader.ts +289 -0
- package/cli/dcv.js +4 -0
- package/cli/dcv.ts +111 -107
- package/cli/engine-helpers.d.ts +33 -0
- package/cli/engine-helpers.d.ts.map +1 -1
- package/cli/engine-helpers.js +87 -22
- package/cli/engine-helpers.ts +133 -61
- package/cli/graph-poset.ts +74 -74
- package/cli/json-output.d.ts +64 -0
- package/cli/json-output.d.ts.map +1 -0
- package/cli/json-output.js +107 -0
- package/cli/json-output.ts +177 -0
- package/cli/result.ts +27 -27
- package/cli/run.ts +54 -54
- package/cli/smoke-test.ts +40 -40
- package/cli/types.d.ts +6 -0
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +84 -78
- package/core/breakpoints.ts +50 -50
- package/core/cli-format.ts +31 -31
- package/core/color.ts +148 -148
- package/core/constraints/cross-axis.ts +114 -114
- package/core/constraints/monotonic-lightness.ts +38 -38
- package/core/constraints/monotonic.ts +74 -74
- package/core/constraints/threshold.ts +43 -43
- package/core/constraints/wcag.ts +70 -70
- package/core/cross-axis-config.d.ts +29 -0
- package/core/cross-axis-config.d.ts.map +1 -1
- package/core/cross-axis-config.js +29 -0
- package/core/cross-axis-config.ts +181 -151
- package/core/engine.d.ts +95 -0
- package/core/engine.d.ts.map +1 -1
- package/core/engine.js +22 -0
- package/core/engine.ts +167 -65
- package/core/flatten.ts +116 -116
- package/core/image-export.ts +48 -48
- package/core/index.d.ts +9 -30
- package/core/index.d.ts.map +1 -1
- package/core/index.js +7 -54
- package/core/index.ts +10 -72
- package/core/patch.ts +134 -134
- package/core/poset.ts +311 -311
- package/core/why.ts +63 -63
- package/package.json +96 -90
- package/themes/color.lg.order.json +15 -15
- package/themes/color.md.order.json +15 -15
- package/themes/color.order.json +15 -15
- package/themes/color.sm.order.json +15 -15
- package/themes/cross-axis.rules.json +35 -35
- package/themes/cross-axis.sm.rules.json +12 -12
- package/themes/layout.lg.order.json +18 -18
- package/themes/layout.md.order.json +18 -18
- package/themes/layout.order.json +18 -18
- package/themes/layout.sm.order.json +18 -18
- package/themes/spacing.order.json +14 -14
- package/themes/typography.lg.order.json +15 -15
- package/themes/typography.md.order.json +15 -15
- package/themes/typography.order.json +15 -15
- package/themes/typography.sm.order.json +15 -15
- package/dist/test-overrides-removal.json +0 -4
- package/dist/tmp.patch.json +0 -35
- package/tokens/overrides/base.json +0 -22
- package/tokens/overrides/lg.json +0 -20
- package/tokens/overrides/md.json +0 -16
- package/tokens/overrides/sm.json +0 -16
- package/tokens/overrides/viol.color.json +0 -6
- package/tokens/overrides/viol.typography.json +0 -6
- package/tokens/tokens.demo-violations.json +0 -116
- package/tokens/tokens.example.json +0 -128
- package/tokens/tokens.json +0 -67
- package/tokens/tokens.multi-violations.json +0 -21
- package/tokens/tokens.schema.d.ts +0 -2298
- package/tokens/tokens.schema.d.ts.map +0 -1
- package/tokens/tokens.schema.js +0 -148
- package/tokens/tokens.schema.ts +0 -196
- package/tokens/tokens.test.json +0 -38
- package/tokens/tokens.touch-violation.json +0 -8
- package/tokens/typography.classes.css +0 -11
- 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
|
+
}
|