@clhaas/palette-kit 0.3.0 → 0.4.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 (312) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +80 -87
  3. package/dist/contrast/contrast.d.ts +16 -0
  4. package/dist/contrast/contrast.js +102 -0
  5. package/dist/core/intent-registry.d.ts +11 -0
  6. package/dist/core/intent-registry.js +70 -0
  7. package/dist/core/oklch.d.ts +16 -0
  8. package/dist/core/oklch.js +56 -0
  9. package/dist/create-palette-kit.d.ts +9 -0
  10. package/dist/create-palette-kit.js +67 -0
  11. package/dist/engine/context/context.d.ts +13 -0
  12. package/dist/engine/context/context.js +37 -0
  13. package/dist/engine/level/curves.d.ts +17 -0
  14. package/dist/engine/level/curves.js +49 -0
  15. package/dist/engine/level/level.d.ts +4 -0
  16. package/dist/engine/level/level.js +13 -0
  17. package/dist/engine/relation/relation.d.ts +105 -0
  18. package/dist/engine/relation/relation.js +137 -0
  19. package/dist/engine/resolve/resolve.d.ts +36 -0
  20. package/dist/engine/resolve/resolve.js +116 -0
  21. package/dist/engine/state/state.d.ts +46 -0
  22. package/dist/engine/state/state.js +68 -0
  23. package/dist/engine/usage/fill.d.ts +9 -0
  24. package/dist/engine/usage/fill.js +9 -0
  25. package/dist/engine/usage/lines.d.ts +9 -0
  26. package/dist/engine/usage/lines.js +9 -0
  27. package/dist/engine/usage/overlays.d.ts +9 -0
  28. package/dist/engine/usage/overlays.js +9 -0
  29. package/dist/engine/usage/strategy.d.ts +56 -0
  30. package/dist/engine/usage/strategy.js +30 -0
  31. package/dist/engine/usage/visualVocabulary.d.ts +9 -0
  32. package/dist/engine/usage/visualVocabulary.js +9 -0
  33. package/dist/export/serialize.d.ts +14 -0
  34. package/dist/export/serialize.js +89 -0
  35. package/dist/export/types.d.ts +37 -0
  36. package/dist/export/types.js +31 -0
  37. package/dist/index.d.ts +3 -3
  38. package/dist/index.js +2 -2
  39. package/dist/operators/convert.d.ts +32 -0
  40. package/dist/operators/convert.js +80 -0
  41. package/dist/presets/presets.d.ts +95 -0
  42. package/dist/presets/presets.js +308 -0
  43. package/dist/types/index.d.ts +111 -187
  44. package/dist/utils/errors/errors.d.ts +17 -0
  45. package/dist/utils/errors/errors.js +22 -0
  46. package/docs/API.md +167 -0
  47. package/docs/Alpha.md +14 -0
  48. package/docs/Architecture.md +56 -0
  49. package/docs/CLI.md +22 -0
  50. package/docs/Concepts.md +73 -0
  51. package/docs/Config.md +144 -0
  52. package/docs/Diagnostics.md +22 -0
  53. package/docs/Exporters.md +33 -0
  54. package/docs/FAQ.md +59 -0
  55. package/docs/Migration.md +61 -0
  56. package/docs/Overlays.md +33 -0
  57. package/docs/README.md +60 -0
  58. package/docs/Text.md +41 -0
  59. package/docs/Tokens.md +42 -0
  60. package/docs/Usage-JSON.md +39 -0
  61. package/docs/Usage-ReactNative.md +63 -0
  62. package/docs/Usage-Web.md +66 -0
  63. package/docs/Validation.md +97 -0
  64. package/docs/Why.md +37 -0
  65. package/docs/_api-surface.md +53 -0
  66. package/docs/snippets/serialize-oklch.md +9 -0
  67. package/docs/spec.md +98 -0
  68. package/package.json +74 -59
  69. package/.codex/skills/color-pipeline-implementer/SKILL.md +0 -23
  70. package/.codex/skills/commit-message-crafter/SKILL.md +0 -63
  71. package/.codex/skills/commit-message-crafter/references/benchmarks.md +0 -20
  72. package/.codex/skills/contrast-solver-helper/SKILL.md +0 -20
  73. package/.codex/skills/exporters-builder/SKILL.md +0 -20
  74. package/.codex/skills/markdownlint-writer/SKILL.md +0 -32
  75. package/.codex/skills/phase-implementation-runbook/SKILL.md +0 -92
  76. package/.codex/skills/type-contract-auditor/SKILL.md +0 -21
  77. package/.github/skills/review-guide/SKILL.md +0 -23
  78. package/.github/skills/review-guide/references/review-guide-v0.3.md +0 -629
  79. package/.markdownlint.json +0 -4
  80. package/AGENTS.md +0 -16
  81. package/biome.json +0 -43
  82. package/dist/cli/args.d.ts +0 -12
  83. package/dist/cli/args.js +0 -56
  84. package/dist/cli/args.test.d.ts +0 -1
  85. package/dist/cli/args.test.js +0 -22
  86. package/dist/cli/codegen/__snapshots__/tokens.test.js.snap +0 -87
  87. package/dist/cli/codegen/tokens.d.ts +0 -12
  88. package/dist/cli/codegen/tokens.js +0 -139
  89. package/dist/cli/codegen/tokens.test.d.ts +0 -1
  90. package/dist/cli/codegen/tokens.test.js +0 -51
  91. package/dist/cli/config.d.ts +0 -40
  92. package/dist/cli/config.js +0 -34
  93. package/dist/cli/validate.d.ts +0 -2
  94. package/dist/cli/validate.js +0 -33
  95. package/dist/cli/validate.test.d.ts +0 -1
  96. package/dist/cli/validate.test.js +0 -40
  97. package/dist/cli.d.ts +0 -2
  98. package/dist/cli.js +0 -148
  99. package/dist/contrast/apca.d.ts +0 -2
  100. package/dist/contrast/apca.js +0 -15
  101. package/dist/contrast/apca.test.d.ts +0 -1
  102. package/dist/contrast/apca.test.js +0 -16
  103. package/dist/contrast/index.d.ts +0 -4
  104. package/dist/contrast/index.js +0 -4
  105. package/dist/contrast/scoring.d.ts +0 -4
  106. package/dist/contrast/scoring.js +0 -31
  107. package/dist/contrast/scoring.test.d.ts +0 -1
  108. package/dist/contrast/scoring.test.js +0 -148
  109. package/dist/contrast/solver.d.ts +0 -13
  110. package/dist/contrast/solver.js +0 -170
  111. package/dist/contrast/solver.test.d.ts +0 -1
  112. package/dist/contrast/solver.test.js +0 -75
  113. package/dist/contrast/types.d.ts +0 -17
  114. package/dist/contrast/types.js +0 -1
  115. package/dist/contrast/utils.d.ts +0 -4
  116. package/dist/contrast/utils.js +0 -18
  117. package/dist/contrast/wcag2.d.ts +0 -3
  118. package/dist/contrast/wcag2.js +0 -19
  119. package/dist/contrast/wcag2.test.d.ts +0 -1
  120. package/dist/contrast/wcag2.test.js +0 -17
  121. package/dist/core/createTheme.d.ts +0 -35
  122. package/dist/core/createTheme.js +0 -24
  123. package/dist/core/dx-helpers.test.d.ts +0 -1
  124. package/dist/core/dx-helpers.test.js +0 -61
  125. package/dist/core/index.d.ts +0 -2
  126. package/dist/core/index.js +0 -2
  127. package/dist/core/onSolid.test.d.ts +0 -1
  128. package/dist/core/onSolid.test.js +0 -118
  129. package/dist/core/qa.v1.test.d.ts +0 -1
  130. package/dist/core/qa.v1.test.js +0 -112
  131. package/dist/core/resolve.d.ts +0 -3
  132. package/dist/core/resolve.js +0 -8
  133. package/dist/core/resolve.test.d.ts +0 -1
  134. package/dist/core/resolve.test.js +0 -89
  135. package/dist/core/resolveMany.d.ts +0 -8
  136. package/dist/core/resolveMany.js +0 -17
  137. package/dist/core/tokenRegistry.d.ts +0 -23
  138. package/dist/core/tokenRegistry.js +0 -83
  139. package/dist/core/tokenRegistry.test.d.ts +0 -1
  140. package/dist/core/tokenRegistry.test.js +0 -133
  141. package/dist/engine/applyOperators.d.ts +0 -3
  142. package/dist/engine/applyOperators.js +0 -23
  143. package/dist/engine/context.d.ts +0 -4
  144. package/dist/engine/context.js +0 -1
  145. package/dist/engine/gamut.d.ts +0 -13
  146. package/dist/engine/gamut.js +0 -101
  147. package/dist/engine/gamut.test.d.ts +0 -1
  148. package/dist/engine/gamut.test.js +0 -23
  149. package/dist/engine/generateScale.d.ts +0 -15
  150. package/dist/engine/generateScale.js +0 -29
  151. package/dist/engine/generateScale.test.d.ts +0 -1
  152. package/dist/engine/generateScale.test.js +0 -32
  153. package/dist/engine/index.d.ts +0 -8
  154. package/dist/engine/index.js +0 -4
  155. package/dist/engine/normalize.d.ts +0 -43
  156. package/dist/engine/normalize.js +0 -403
  157. package/dist/engine/normalize.test.d.ts +0 -1
  158. package/dist/engine/normalize.test.js +0 -136
  159. package/dist/engine/onSolid.d.ts +0 -3
  160. package/dist/engine/onSolid.js +0 -110
  161. package/dist/engine/resolveBaseColor.d.ts +0 -25
  162. package/dist/engine/resolveBaseColor.js +0 -127
  163. package/dist/engine/resolveBaseColor.test.d.ts +0 -1
  164. package/dist/engine/resolveBaseColor.test.js +0 -97
  165. package/dist/export/__snapshots__/exportTheme.test.js.snap +0 -74
  166. package/dist/export/exportTheme.d.ts +0 -47
  167. package/dist/export/exportTheme.js +0 -170
  168. package/dist/export/exportTheme.test.d.ts +0 -1
  169. package/dist/export/exportTheme.test.js +0 -118
  170. package/dist/export/index.d.ts +0 -1
  171. package/dist/export/index.js +0 -1
  172. package/dist/export/serializeColor.d.ts +0 -1
  173. package/dist/export/serializeColor.js +0 -1
  174. package/dist/export/serializeColor.test.d.ts +0 -1
  175. package/dist/export/serializeColor.test.js +0 -54
  176. package/dist/export.d.ts +0 -1
  177. package/dist/export.js +0 -1
  178. package/dist/operators/emphasis.d.ts +0 -3
  179. package/dist/operators/emphasis.js +0 -113
  180. package/dist/operators/emphasis.test.d.ts +0 -1
  181. package/dist/operators/emphasis.test.js +0 -69
  182. package/dist/operators/index.d.ts +0 -3
  183. package/dist/operators/index.js +0 -2
  184. package/dist/operators/state.d.ts +0 -3
  185. package/dist/operators/state.js +0 -102
  186. package/dist/operators/state.test.d.ts +0 -1
  187. package/dist/operators/state.test.js +0 -48
  188. package/dist/operators/types.d.ts +0 -13
  189. package/dist/operators/types.js +0 -1
  190. package/dist/operators/utils.d.ts +0 -16
  191. package/dist/operators/utils.js +0 -23
  192. package/dist/presets/curves.d.ts +0 -28
  193. package/dist/presets/curves.js +0 -145
  194. package/dist/presets/index.d.ts +0 -2
  195. package/dist/presets/index.js +0 -1
  196. package/dist/presets/tokens/index.d.ts +0 -3
  197. package/dist/presets/tokens/index.js +0 -3
  198. package/dist/presets/tokens/minimal-ui.d.ts +0 -6
  199. package/dist/presets/tokens/minimal-ui.js +0 -53
  200. package/dist/presets/tokens/modern-ui.d.ts +0 -5
  201. package/dist/presets/tokens/modern-ui.js +0 -83
  202. package/dist/presets/tokens/presets.test.d.ts +0 -1
  203. package/dist/presets/tokens/presets.test.js +0 -31
  204. package/dist/presets/tokens/radixLike-ui.d.ts +0 -6
  205. package/dist/presets/tokens/radixLike-ui.js +0 -77
  206. package/dist/serialize/index.d.ts +0 -1
  207. package/dist/serialize/index.js +0 -1
  208. package/dist/serialize/normalizeOutput.d.ts +0 -6
  209. package/dist/serialize/normalizeOutput.js +0 -45
  210. package/dist/serialize/serializeColor.d.ts +0 -21
  211. package/dist/serialize/serializeColor.js +0 -178
  212. package/dist/serialize/serializeResolved.test.d.ts +0 -1
  213. package/dist/serialize/serializeResolved.test.js +0 -45
  214. package/dist/serialize.d.ts +0 -1
  215. package/dist/serialize.js +0 -1
  216. package/dist/utils/clamp.d.ts +0 -1
  217. package/dist/utils/clamp.js +0 -1
  218. package/dist/utils/index.d.ts +0 -1
  219. package/dist/utils/index.js +0 -1
  220. package/dist/utils/lerp.d.ts +0 -1
  221. package/dist/utils/lerp.js +0 -1
  222. package/dist/utils/parseColor.d.ts +0 -6
  223. package/dist/utils/parseColor.js +0 -67
  224. package/dist/utils/parseColor.test.d.ts +0 -1
  225. package/dist/utils/parseColor.test.js +0 -51
  226. package/dist/utils/smoothstep.d.ts +0 -1
  227. package/dist/utils/smoothstep.js +0 -5
  228. package/planning/phase-10-review.md +0 -550
  229. package/planning/phase-7-review.md +0 -411
  230. package/planning/phase-8-review.md +0 -669
  231. package/planning/phase-9-review.md +0 -564
  232. package/planning/roadmap-v0.3.md +0 -284
  233. package/planning/spec-serializer-v0.3.md +0 -324
  234. package/planning/spec-v0.3.md +0 -305
  235. package/src/cli/args.test.ts +0 -28
  236. package/src/cli/args.ts +0 -66
  237. package/src/cli/codegen/__snapshots__/tokens.test.ts.snap +0 -87
  238. package/src/cli/codegen/tokens.test.ts +0 -61
  239. package/src/cli/codegen/tokens.ts +0 -191
  240. package/src/cli/config.ts +0 -71
  241. package/src/cli/validate.test.ts +0 -49
  242. package/src/cli/validate.ts +0 -38
  243. package/src/cli.ts +0 -183
  244. package/src/contrast/apca.test.ts +0 -20
  245. package/src/contrast/apca.ts +0 -26
  246. package/src/contrast/index.ts +0 -4
  247. package/src/contrast/scoring.test.ts +0 -188
  248. package/src/contrast/scoring.ts +0 -48
  249. package/src/contrast/solver.test.ts +0 -147
  250. package/src/contrast/solver.ts +0 -235
  251. package/src/contrast/types.ts +0 -20
  252. package/src/contrast/utils.ts +0 -28
  253. package/src/contrast/wcag2.test.ts +0 -21
  254. package/src/contrast/wcag2.ts +0 -24
  255. package/src/core/createTheme.ts +0 -78
  256. package/src/core/dx-helpers.test.ts +0 -82
  257. package/src/core/index.ts +0 -7
  258. package/src/core/onSolid.test.ts +0 -146
  259. package/src/core/qa.v1.test.ts +0 -149
  260. package/src/core/resolve.test.ts +0 -99
  261. package/src/core/resolve.ts +0 -11
  262. package/src/core/resolveMany.ts +0 -22
  263. package/src/core/tokenRegistry.test.ts +0 -153
  264. package/src/core/tokenRegistry.ts +0 -114
  265. package/src/engine/applyOperators.ts +0 -32
  266. package/src/engine/context.ts +0 -8
  267. package/src/engine/gamut.test.ts +0 -30
  268. package/src/engine/gamut.ts +0 -144
  269. package/src/engine/generateScale.test.ts +0 -46
  270. package/src/engine/generateScale.ts +0 -48
  271. package/src/engine/index.ts +0 -8
  272. package/src/engine/normalize.test.ts +0 -222
  273. package/src/engine/normalize.ts +0 -550
  274. package/src/engine/onSolid.ts +0 -178
  275. package/src/engine/resolveBaseColor.test.ts +0 -117
  276. package/src/engine/resolveBaseColor.ts +0 -203
  277. package/src/export/__snapshots__/exportTheme.test.ts.snap +0 -74
  278. package/src/export/exportTheme.test.ts +0 -144
  279. package/src/export/exportTheme.ts +0 -251
  280. package/src/export/index.ts +0 -1
  281. package/src/export/serializeColor.test.ts +0 -73
  282. package/src/export/serializeColor.ts +0 -1
  283. package/src/export.ts +0 -1
  284. package/src/index.ts +0 -3
  285. package/src/operators/emphasis.test.ts +0 -85
  286. package/src/operators/emphasis.ts +0 -132
  287. package/src/operators/index.ts +0 -3
  288. package/src/operators/state.test.ts +0 -66
  289. package/src/operators/state.ts +0 -122
  290. package/src/operators/types.ts +0 -14
  291. package/src/operators/utils.ts +0 -44
  292. package/src/presets/curves.ts +0 -168
  293. package/src/presets/index.ts +0 -2
  294. package/src/presets/tokens/index.ts +0 -3
  295. package/src/presets/tokens/minimal-ui.ts +0 -55
  296. package/src/presets/tokens/modern-ui.ts +0 -85
  297. package/src/presets/tokens/presets.test.ts +0 -46
  298. package/src/presets/tokens/radixLike-ui.ts +0 -79
  299. package/src/serialize/index.ts +0 -1
  300. package/src/serialize/normalizeOutput.ts +0 -63
  301. package/src/serialize/serializeColor.ts +0 -260
  302. package/src/serialize/serializeResolved.test.ts +0 -57
  303. package/src/serialize.ts +0 -1
  304. package/src/types/index.ts +0 -207
  305. package/src/utils/clamp.ts +0 -2
  306. package/src/utils/index.ts +0 -1
  307. package/src/utils/lerp.ts +0 -1
  308. package/src/utils/parseColor.test.ts +0 -66
  309. package/src/utils/parseColor.ts +0 -87
  310. package/src/utils/smoothstep.ts +0 -6
  311. package/tsconfig.build.json +0 -11
  312. package/tsconfig.json +0 -15
@@ -1,188 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { scoreApca, scoreContrast } from "./scoring.js";
4
- import type { ContrastRequirement } from "../types/index.js";
5
- import type { ContrastCheckResult } from "./types.js";
6
-
7
- describe("scoreApca", () => {
8
- it("prefers values closer to target within range", () => {
9
- const target = 60;
10
- const min = 50;
11
- const max = 80;
12
- const hasMax = true;
13
-
14
- expect(scoreApca(60, target, min, max, hasMax)).toBeGreaterThan(
15
- scoreApca(80, target, min, max, hasMax),
16
- );
17
- });
18
-
19
- it("penalizes values outside the range", () => {
20
- const target = 60;
21
- const min = 50;
22
- const max = 80;
23
- const hasMax = true;
24
-
25
- expect(scoreApca(40, target, min, max, hasMax)).toBeLessThan(0);
26
- expect(scoreApca(90, target, min, max, hasMax)).toBeLessThan(0);
27
- });
28
-
29
- it("penalizes below min and prefers target when no max", () => {
30
- const target = 60;
31
- const min = 50;
32
- const max = Number.POSITIVE_INFINITY;
33
- const hasMax = false;
34
-
35
- expect(scoreApca(40, target, min, max, hasMax)).toBeLessThan(
36
- scoreApca(55, target, min, max, hasMax),
37
- );
38
- expect(scoreApca(60, target, min, max, hasMax)).toBeGreaterThan(
39
- scoreApca(70, target, min, max, hasMax),
40
- );
41
- });
42
-
43
- it("returns NEGATIVE_INFINITY for NaN values", () => {
44
- const target = 60;
45
- const min = 50;
46
- const max = 80;
47
- const hasMax = true;
48
-
49
- expect(scoreApca(Number.NaN, target, min, max, hasMax)).toBe(
50
- Number.NEGATIVE_INFINITY,
51
- );
52
- });
53
- });
54
-
55
- describe("scoreContrast", () => {
56
- describe("with APCA contrast model", () => {
57
- it("delegates to scoreApca with correct parameters when maxLc is defined", () => {
58
- const result: ContrastCheckResult = {
59
- model: "apca",
60
- target: 60,
61
- value: 65,
62
- pass: true,
63
- };
64
- const req: ContrastRequirement = {
65
- model: "apca",
66
- targetLc: 60,
67
- minLc: 50,
68
- maxLc: 80,
69
- };
70
-
71
- const score = scoreContrast(result, req);
72
- const expectedScore = scoreApca(65, 60, 50, 80, true);
73
-
74
- expect(score).toBe(expectedScore);
75
- });
76
-
77
- it("delegates to scoreApca without max when maxLc is undefined", () => {
78
- const result: ContrastCheckResult = {
79
- model: "apca",
80
- target: 60,
81
- value: 65,
82
- pass: true,
83
- };
84
- const req: ContrastRequirement = {
85
- model: "apca",
86
- targetLc: 60,
87
- minLc: 50,
88
- };
89
-
90
- const score = scoreContrast(result, req);
91
- const expectedScore = scoreApca(65, 60, 50, Number.POSITIVE_INFINITY, false);
92
-
93
- expect(score).toBe(expectedScore);
94
- });
95
-
96
- it("uses targetLc as min when minLc is undefined", () => {
97
- const result: ContrastCheckResult = {
98
- model: "apca",
99
- target: 60,
100
- value: 65,
101
- pass: true,
102
- };
103
- const req: ContrastRequirement = {
104
- model: "apca",
105
- targetLc: 60,
106
- };
107
-
108
- const score = scoreContrast(result, req);
109
- const expectedScore = scoreApca(65, 60, 60, Number.POSITIVE_INFINITY, false);
110
-
111
- expect(score).toBe(expectedScore);
112
- });
113
- });
114
-
115
- describe("with WCAG2 contrast model", () => {
116
- it("returns the raw contrast value", () => {
117
- const result: ContrastCheckResult = {
118
- model: "wcag2",
119
- target: 4.5,
120
- value: 7.2,
121
- pass: true,
122
- };
123
- const req: ContrastRequirement = {
124
- model: "wcag2",
125
- minRatio: 4.5,
126
- };
127
-
128
- const score = scoreContrast(result, req);
129
-
130
- expect(score).toBe(7.2);
131
- });
132
- });
133
-
134
- describe("with NaN or non-finite values", () => {
135
- it("returns NEGATIVE_INFINITY for NaN", () => {
136
- const result: ContrastCheckResult = {
137
- model: "apca",
138
- target: 60,
139
- value: Number.NaN,
140
- pass: false,
141
- };
142
- const req: ContrastRequirement = {
143
- model: "apca",
144
- targetLc: 60,
145
- minLc: 50,
146
- };
147
-
148
- const score = scoreContrast(result, req);
149
-
150
- expect(score).toBe(Number.NEGATIVE_INFINITY);
151
- });
152
-
153
- it("returns NEGATIVE_INFINITY for positive infinity", () => {
154
- const result: ContrastCheckResult = {
155
- model: "apca",
156
- target: 60,
157
- value: Number.POSITIVE_INFINITY,
158
- pass: false,
159
- };
160
- const req: ContrastRequirement = {
161
- model: "apca",
162
- targetLc: 60,
163
- minLc: 50,
164
- };
165
-
166
- const score = scoreContrast(result, req);
167
-
168
- expect(score).toBe(Number.NEGATIVE_INFINITY);
169
- });
170
-
171
- it("returns NEGATIVE_INFINITY for negative infinity", () => {
172
- const result: ContrastCheckResult = {
173
- model: "wcag2",
174
- target: 4.5,
175
- value: Number.NEGATIVE_INFINITY,
176
- pass: false,
177
- };
178
- const req: ContrastRequirement = {
179
- model: "wcag2",
180
- minRatio: 4.5,
181
- };
182
-
183
- const score = scoreContrast(result, req);
184
-
185
- expect(score).toBe(Number.NEGATIVE_INFINITY);
186
- });
187
- });
188
- });
@@ -1,48 +0,0 @@
1
- import type { ContrastRequirement } from "../types/index.js";
2
- import type { ContrastCheckResult } from "./types.js";
3
-
4
- export const scoreApca = (
5
- value: number,
6
- target: number,
7
- min: number,
8
- max: number,
9
- hasMax: boolean,
10
- ) => {
11
- if (Number.isNaN(value)) {
12
- return Number.NEGATIVE_INFINITY;
13
- }
14
-
15
- if (hasMax) {
16
- if (value >= min && value <= max) {
17
- return 1000 - Math.abs(value - target);
18
- }
19
-
20
- if (value < min) {
21
- return -(min - value);
22
- }
23
-
24
- return -(value - max);
25
- }
26
-
27
- if (value < min) {
28
- const distance = min - value;
29
- return -distance * 10 - Math.abs(value - target);
30
- }
31
-
32
- return -Math.abs(value - target);
33
- };
34
-
35
- export const scoreContrast = (result: ContrastCheckResult, req: ContrastRequirement) => {
36
- if (!Number.isFinite(result.value)) {
37
- return Number.NEGATIVE_INFINITY;
38
- }
39
-
40
- if (req.model === "apca") {
41
- const min = req.minLc ?? req.targetLc;
42
- const hasMax = req.maxLc !== undefined;
43
- const max = req.maxLc ?? Number.POSITIVE_INFINITY;
44
- return scoreApca(result.value, req.targetLc, min, max, hasMax);
45
- }
46
-
47
- return result.value;
48
- };
@@ -1,147 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { parseColor } from "../utils/parseColor.js";
4
- import { solveContrast } from "./solver.js";
5
-
6
- const toOkLch = (hex: string) => {
7
- const parsed = parseColor(hex);
8
- return {
9
- l: parsed.okLch.channels[0] * 100,
10
- c: parsed.okLch.channels[1],
11
- h: parsed.okLch.channels[2],
12
- alpha: parsed.okLch.alpha,
13
- };
14
- };
15
-
16
- describe("solveContrast", () => {
17
- it("raises contrast on light backgrounds", () => {
18
- const fg = toOkLch("#777777");
19
- const bg = toOkLch("#ffffff");
20
-
21
- const result = solveContrast(
22
- fg,
23
- bg,
24
- { model: "wcag2", minRatio: 4.5 },
25
- { surface: "surface", context: "light" },
26
- );
27
-
28
- expect(result.result.pass).toBe(true);
29
- expect(result.result.value).toBeGreaterThanOrEqual(4.5);
30
- expect(result.color.l).toBeGreaterThanOrEqual(0);
31
- expect(result.color.l).toBeLessThanOrEqual(100);
32
- expect(result.color.c).toBeGreaterThanOrEqual(0);
33
- });
34
-
35
- it("returns original color when contrast model is none", () => {
36
- const fg = toOkLch("#777777");
37
- const bg = toOkLch("#ffffff");
38
-
39
- const result = solveContrast(
40
- fg,
41
- bg,
42
- { model: "none" },
43
- { surface: "surface", context: "light" },
44
- );
45
-
46
- expect(result.result.pass).toBe(true);
47
- expect(result.iterations).toBe(0);
48
- expect(result.color).toEqual(fg);
49
- });
50
-
51
- it("skips when background is missing and strict is false", () => {
52
- const fg = toOkLch("#777777");
53
-
54
- const result = solveContrast(
55
- fg,
56
- undefined,
57
- { model: "wcag2", minRatio: 4.5 },
58
- { surface: "surface", context: "light" },
59
- { strict: false },
60
- );
61
-
62
- expect(result.result.pass).toBe(false);
63
- expect(Number.isNaN(result.result.value)).toBe(true);
64
- });
65
-
66
- it("throws when background is missing and strict is true", () => {
67
- const fg = toOkLch("#777777");
68
-
69
- expect(() =>
70
- solveContrast(
71
- fg,
72
- undefined,
73
- { model: "wcag2", minRatio: 4.5 },
74
- { surface: "surface", context: "light" },
75
- { strict: true },
76
- ),
77
- ).toThrowError(/requires background/i);
78
- });
79
-
80
- it("raises contrast on dark backgrounds", () => {
81
- const fg = toOkLch("#777777");
82
- const bg = toOkLch("#111111");
83
-
84
- const result = solveContrast(
85
- fg,
86
- bg,
87
- { model: "wcag2", minRatio: 4.5 },
88
- { surface: "surface", context: "dark" },
89
- );
90
-
91
- expect(result.result.pass).toBe(true);
92
- expect(result.result.value).toBeGreaterThanOrEqual(4.5);
93
- });
94
-
95
- it("keeps hue stable", () => {
96
- const fg = toOkLch("#3366ff");
97
- const bg = toOkLch("#ffffff");
98
-
99
- const result = solveContrast(
100
- fg,
101
- bg,
102
- { model: "wcag2", minRatio: 4.5 },
103
- { surface: "surface", context: "light" },
104
- );
105
-
106
- expect(result.color.h).toBeCloseTo(fg.h, 6);
107
- });
108
-
109
- it("throws in strict mode when target is unattainable", () => {
110
- const fg = toOkLch("#ffffff");
111
- const bg = toOkLch("#ffffff");
112
-
113
- expect(() =>
114
- solveContrast(
115
- fg,
116
- bg,
117
- { model: "wcag2", minRatio: 30 },
118
- { surface: "surface", context: "light" },
119
- { strict: true },
120
- ),
121
- ).toThrowError(/contrast solver failed/i);
122
- });
123
-
124
- it("prefers APCA values closer to target within the allowed range", () => {
125
- const fg = toOkLch("#777777");
126
- const bg = toOkLch("#ffffff");
127
- const minLc = 50;
128
- const maxLc = 80;
129
- const targetLc = 60;
130
-
131
- const result = solveContrast(
132
- fg,
133
- bg,
134
- { model: "apca", targetLc, minLc, maxLc },
135
- { surface: "surface", context: "light" },
136
- );
137
-
138
- const value = result.result.value;
139
- const targetDistance = Math.abs(value - targetLc);
140
- const maxDistance = Math.abs(maxLc - targetLc);
141
-
142
- expect(result.result.pass).toBe(true);
143
- expect(value).toBeGreaterThanOrEqual(minLc);
144
- expect(value).toBeLessThanOrEqual(maxLc);
145
- expect(targetDistance).toBeLessThanOrEqual(maxDistance);
146
- });
147
- });
@@ -1,235 +0,0 @@
1
- import { converter } from "culori";
2
- import type { OkLchColor } from "../engine/generateScale.js";
3
- import { getSurfaceRange } from "../operators/utils.js";
4
- import type { CurvePresetName } from "../presets/index.js";
5
- import type { ContrastRequirement, SurfaceIntent } from "../types/index.js";
6
- import { computeApcaLc } from "./apca.js";
7
- import { scoreContrast } from "./scoring.js";
8
- import type { ContrastCheckResult, SolveOptions, SrgbColor } from "./types.js";
9
- import { contrastRatio } from "./wcag2.js";
10
-
11
- const toSrgb = converter("rgb");
12
-
13
- const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
14
-
15
- const clampOkLch = (color: OkLchColor, cMax: number): OkLchColor => ({
16
- l: clamp(color.l, 0, 100),
17
- c: clamp(color.c, 0, cMax),
18
- h: color.h,
19
- alpha: color.alpha,
20
- });
21
-
22
- const clampOkLchLoose = (color: OkLchColor): OkLchColor => ({
23
- l: clamp(color.l, 0, 100),
24
- c: Math.max(0, color.c),
25
- h: color.h,
26
- alpha: color.alpha,
27
- });
28
-
29
- const toSrgbColor = (color: OkLchColor): SrgbColor | null => {
30
- const rgb = toSrgb({ mode: "oklch", l: clamp(color.l, 0, 100) / 100, c: color.c, h: color.h });
31
-
32
- if (!rgb) {
33
- return null;
34
- }
35
-
36
- const r = typeof rgb.r === "number" && Number.isFinite(rgb.r) ? clamp(rgb.r, 0, 1) : 0;
37
- const g = typeof rgb.g === "number" && Number.isFinite(rgb.g) ? clamp(rgb.g, 0, 1) : 0;
38
- const b = typeof rgb.b === "number" && Number.isFinite(rgb.b) ? clamp(rgb.b, 0, 1) : 0;
39
-
40
- return { r, g, b };
41
- };
42
-
43
- const getTarget = (req: ContrastRequirement): number => {
44
- if (req.model === "apca") {
45
- return req.targetLc;
46
- }
47
-
48
- if (req.model === "wcag2") {
49
- return req.minRatio;
50
- }
51
-
52
- return 0;
53
- };
54
-
55
- const checkContrast = (
56
- fg: OkLchColor,
57
- bg: OkLchColor,
58
- req: ContrastRequirement,
59
- epsilon: number,
60
- ): ContrastCheckResult => {
61
- if (req.model === "none") {
62
- return { model: "none", target: 0, value: 0, pass: true };
63
- }
64
-
65
- const fgSrgb = toSrgbColor(fg);
66
- const bgSrgb = toSrgbColor(bg);
67
- const target = getTarget(req);
68
-
69
- if (!fgSrgb || !bgSrgb) {
70
- return { model: req.model, target, value: Number.NaN, pass: false };
71
- }
72
-
73
- if (req.model === "apca") {
74
- const value = Math.abs(computeApcaLc(fgSrgb, bgSrgb));
75
- const minTarget = req.minLc ?? req.targetLc;
76
- const maxTarget = req.maxLc ?? Number.POSITIVE_INFINITY;
77
- const pass =
78
- Number.isFinite(value) && value >= minTarget - epsilon && value <= maxTarget + epsilon;
79
- return { model: "apca", target, value, pass };
80
- }
81
-
82
- const value = contrastRatio(fgSrgb, bgSrgb);
83
- const pass = Number.isFinite(value) && value + epsilon >= target;
84
- return { model: "wcag2", target, value, pass };
85
- };
86
-
87
- const pickBetter = (
88
- current: { color: OkLchColor; result: ContrastCheckResult },
89
- candidate: { color: OkLchColor; result: ContrastCheckResult },
90
- req: ContrastRequirement,
91
- ) => {
92
- const currentScore = scoreContrast(current.result, req);
93
- const candidateScore = scoreContrast(candidate.result, req);
94
- return candidateScore > currentScore ? candidate : current;
95
- };
96
-
97
- export function solveContrast(
98
- fg: OkLchColor,
99
- bg: OkLchColor | undefined,
100
- req: ContrastRequirement,
101
- ctx: { preset?: CurvePresetName; surface: SurfaceIntent; context: "light" | "dark" },
102
- opts?: SolveOptions,
103
- ): { color: OkLchColor; result: ContrastCheckResult; iterations: number } {
104
- const options: Required<SolveOptions> = {
105
- strict: false,
106
- maxIterations: 24,
107
- epsilon: 0.01,
108
- ...opts,
109
- };
110
-
111
- if (req.model === "none") {
112
- return {
113
- color: fg,
114
- result: { model: "none", target: 0, value: 0, pass: true },
115
- iterations: 0,
116
- };
117
- }
118
-
119
- if (!bg) {
120
- if (options.strict) {
121
- throw new Error("Contrast solver requires background");
122
- }
123
-
124
- return {
125
- color: fg,
126
- result: { model: req.model, target: getTarget(req), value: Number.NaN, pass: false },
127
- iterations: 0,
128
- };
129
- }
130
-
131
- const range = getSurfaceRange(ctx.preset, ctx.surface, ctx.context);
132
- const clamped = clampOkLch(fg, range.cMax);
133
- const background = clampOkLchLoose(bg);
134
- let iterations = 0;
135
-
136
- const evaluate = (color: OkLchColor) => {
137
- const result = checkContrast(color, background, req, options.epsilon);
138
- iterations += 1;
139
- return result;
140
- };
141
-
142
- let best = { color: clamped, result: evaluate(clamped) };
143
-
144
- if (best.result.pass) {
145
- return { ...best, iterations };
146
- }
147
-
148
- const lMin = 0;
149
- const lMax = 100;
150
- const sampleT = 0.25;
151
- const sampleDown = clamp(clamped.l + (lMin - clamped.l) * sampleT, lMin, lMax);
152
- const sampleUp = clamp(clamped.l + (lMax - clamped.l) * sampleT, lMin, lMax);
153
-
154
- let preferredBound = lMin;
155
-
156
- if (iterations < options.maxIterations) {
157
- const downCandidate = {
158
- color: { ...clamped, l: sampleDown },
159
- result: evaluate({ ...clamped, l: sampleDown }),
160
- };
161
- best = pickBetter(best, downCandidate, req);
162
-
163
- if (best.result.pass) {
164
- return { ...best, iterations };
165
- }
166
-
167
- if (iterations < options.maxIterations) {
168
- const upCandidate = {
169
- color: { ...clamped, l: sampleUp },
170
- result: evaluate({ ...clamped, l: sampleUp }),
171
- };
172
- best = pickBetter(best, upCandidate, req);
173
-
174
- if (best.result.pass) {
175
- return { ...best, iterations };
176
- }
177
-
178
- preferredBound =
179
- scoreContrast(upCandidate.result, req) > scoreContrast(downCandidate.result, req)
180
- ? lMax
181
- : lMin;
182
- }
183
- }
184
-
185
- const remainingAfterSamples = Math.max(0, options.maxIterations - iterations);
186
- const lSteps = Math.max(4, remainingAfterSamples);
187
-
188
- for (let step = 1; step <= lSteps && iterations < options.maxIterations; step += 1) {
189
- const t = step / lSteps;
190
- const l = clamp(clamped.l + (preferredBound - clamped.l) * t, lMin, lMax);
191
- const candidateColor = { ...clamped, l };
192
- const result = evaluate(candidateColor);
193
- const candidate = { color: candidateColor, result };
194
- best = pickBetter(best, candidate, req);
195
-
196
- if (result.pass) {
197
- return { color: candidateColor, result, iterations };
198
- }
199
- }
200
-
201
- let current = { ...best.color };
202
-
203
- while (iterations < options.maxIterations && current.c > 0) {
204
- const nextC = clamp(current.c * 0.9, 0, range.cMax);
205
- current = { ...current, c: nextC };
206
- const result = evaluate(current);
207
- best = pickBetter(best, { color: current, result }, req);
208
-
209
- if (result.pass) {
210
- return { color: current, result, iterations };
211
- }
212
-
213
- const sweepSteps = Math.min(3, options.maxIterations - iterations);
214
-
215
- for (let step = 1; step <= sweepSteps && iterations < options.maxIterations; step += 1) {
216
- const t = step / sweepSteps;
217
- const l = clamp(current.l + (preferredBound - current.l) * t, lMin, lMax);
218
- const candidateColor = { ...current, l };
219
- const candidateResult = evaluate(candidateColor);
220
- best = pickBetter(best, { color: candidateColor, result: candidateResult }, req);
221
-
222
- if (candidateResult.pass) {
223
- return { color: candidateColor, result: candidateResult, iterations };
224
- }
225
- }
226
- }
227
-
228
- if (options.strict) {
229
- throw new Error(
230
- `Contrast solver failed (${best.result.model}) target=${best.result.target} value=${best.result.value} iterations=${iterations}`,
231
- );
232
- }
233
-
234
- return { color: best.color, result: best.result, iterations };
235
- }
@@ -1,20 +0,0 @@
1
- export type ContrastModel = "apca" | "wcag2" | "none";
2
-
3
- export type ContrastCheckResult = {
4
- model: ContrastModel;
5
- target: number;
6
- value: number;
7
- pass: boolean;
8
- };
9
-
10
- export type SolveOptions = {
11
- strict?: boolean;
12
- maxIterations?: number;
13
- epsilon?: number;
14
- };
15
-
16
- export type SrgbColor = {
17
- r: number;
18
- g: number;
19
- b: number;
20
- };
@@ -1,28 +0,0 @@
1
- import { converter } from "culori";
2
-
3
- import type { OkLchColor } from "../engine/generateScale.js";
4
- import type { SrgbColor } from "./types.js";
5
-
6
- const toSrgb = converter("rgb");
7
-
8
- const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
9
-
10
- export const toSrgbColor = (color: OkLchColor): SrgbColor | null => {
11
- const rgb = toSrgb({ mode: "oklch", l: clamp(color.l, 0, 100) / 100, c: color.c, h: color.h });
12
-
13
- if (!rgb) {
14
- return null;
15
- }
16
-
17
- const r = typeof rgb.r === "number" && Number.isFinite(rgb.r) ? clamp(rgb.r, 0, 1) : 0;
18
- const g = typeof rgb.g === "number" && Number.isFinite(rgb.g) ? clamp(rgb.g, 0, 1) : 0;
19
- const b = typeof rgb.b === "number" && Number.isFinite(rgb.b) ? clamp(rgb.b, 0, 1) : 0;
20
-
21
- return { r, g, b };
22
- };
23
-
24
- export const blendSrgb = (fg: SrgbColor, bg: SrgbColor, alpha: number): SrgbColor => ({
25
- r: fg.r * alpha + bg.r * (1 - alpha),
26
- g: fg.g * alpha + bg.g * (1 - alpha),
27
- b: fg.b * alpha + bg.b * (1 - alpha),
28
- });
@@ -1,21 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { contrastRatio } from "./wcag2.js";
4
-
5
- describe("wcag2 contrastRatio", () => {
6
- it("returns high contrast for black on white", () => {
7
- const ratio = contrastRatio({ r: 0, g: 0, b: 0 }, { r: 1, g: 1, b: 1 });
8
- expect(ratio).toBeGreaterThan(10);
9
- });
10
-
11
- it("returns lower contrast for mid gray on white", () => {
12
- const gray = 0x77 / 255;
13
- const ratio = contrastRatio({ r: gray, g: gray, b: gray }, { r: 1, g: 1, b: 1 });
14
- expect(ratio).toBeLessThan(4.5);
15
- });
16
-
17
- it("does not return NaN", () => {
18
- const ratio = contrastRatio({ r: 0.2, g: 0.4, b: 0.6 }, { r: 0.8, g: 0.7, b: 0.1 });
19
- expect(Number.isNaN(ratio)).toBe(false);
20
- });
21
- });