@clhaas/palette-kit 0.1.8 → 0.3.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 (295) hide show
  1. package/.codex/skills/color-pipeline-implementer/SKILL.md +23 -0
  2. package/.codex/skills/commit-message-crafter/SKILL.md +63 -0
  3. package/.codex/skills/commit-message-crafter/references/benchmarks.md +20 -0
  4. package/.codex/skills/contrast-solver-helper/SKILL.md +20 -0
  5. package/.codex/skills/exporters-builder/SKILL.md +20 -0
  6. package/.codex/skills/markdownlint-writer/SKILL.md +32 -0
  7. package/.codex/skills/phase-implementation-runbook/SKILL.md +92 -0
  8. package/.codex/skills/type-contract-auditor/SKILL.md +21 -0
  9. package/.github/skills/review-guide/SKILL.md +23 -0
  10. package/.github/skills/review-guide/references/review-guide-v0.3.md +629 -0
  11. package/.markdownlint.json +4 -0
  12. package/AGENTS.md +16 -0
  13. package/CHANGELOG.md +34 -0
  14. package/README.md +79 -169
  15. package/biome.json +43 -0
  16. package/dist/cli/args.d.ts +12 -0
  17. package/dist/cli/args.js +56 -0
  18. package/dist/cli/args.test.js +22 -0
  19. package/dist/cli/codegen/__snapshots__/tokens.test.js.snap +87 -0
  20. package/dist/cli/codegen/tokens.d.ts +12 -0
  21. package/dist/cli/codegen/tokens.js +139 -0
  22. package/dist/cli/codegen/tokens.test.d.ts +1 -0
  23. package/dist/cli/codegen/tokens.test.js +51 -0
  24. package/dist/cli/config.d.ts +40 -0
  25. package/dist/cli/config.js +34 -0
  26. package/dist/cli/validate.d.ts +2 -0
  27. package/dist/cli/validate.js +33 -0
  28. package/dist/cli/validate.test.d.ts +1 -0
  29. package/dist/cli/validate.test.js +40 -0
  30. package/dist/cli.js +138 -140
  31. package/dist/contrast/apca.d.ts +2 -2
  32. package/dist/contrast/apca.js +14 -4
  33. package/dist/contrast/apca.test.d.ts +1 -0
  34. package/dist/contrast/apca.test.js +16 -0
  35. package/dist/contrast/index.d.ts +4 -0
  36. package/dist/contrast/index.js +4 -0
  37. package/dist/contrast/scoring.d.ts +4 -0
  38. package/dist/contrast/scoring.js +31 -0
  39. package/dist/contrast/scoring.test.d.ts +1 -0
  40. package/dist/contrast/scoring.test.js +148 -0
  41. package/dist/contrast/solver.d.ts +13 -0
  42. package/dist/contrast/solver.js +170 -0
  43. package/dist/contrast/solver.test.d.ts +1 -0
  44. package/dist/contrast/solver.test.js +75 -0
  45. package/dist/contrast/types.d.ts +17 -0
  46. package/dist/contrast/types.js +1 -0
  47. package/dist/contrast/utils.d.ts +4 -0
  48. package/dist/contrast/utils.js +18 -0
  49. package/dist/contrast/wcag2.d.ts +3 -0
  50. package/dist/contrast/wcag2.js +19 -0
  51. package/dist/contrast/wcag2.test.d.ts +1 -0
  52. package/dist/contrast/wcag2.test.js +17 -0
  53. package/dist/core/createTheme.d.ts +35 -0
  54. package/dist/core/createTheme.js +24 -0
  55. package/dist/core/dx-helpers.test.d.ts +1 -0
  56. package/dist/core/dx-helpers.test.js +61 -0
  57. package/dist/core/index.d.ts +2 -0
  58. package/dist/core/index.js +2 -0
  59. package/dist/core/onSolid.test.d.ts +1 -0
  60. package/dist/core/onSolid.test.js +118 -0
  61. package/dist/core/qa.v1.test.d.ts +1 -0
  62. package/dist/core/qa.v1.test.js +112 -0
  63. package/dist/core/resolve.d.ts +3 -0
  64. package/dist/core/resolve.js +8 -0
  65. package/dist/core/resolve.test.d.ts +1 -0
  66. package/dist/core/resolve.test.js +89 -0
  67. package/dist/core/resolveMany.d.ts +8 -0
  68. package/dist/core/resolveMany.js +17 -0
  69. package/dist/core/tokenRegistry.d.ts +23 -0
  70. package/dist/core/tokenRegistry.js +83 -0
  71. package/dist/core/tokenRegistry.test.d.ts +1 -0
  72. package/dist/core/tokenRegistry.test.js +133 -0
  73. package/dist/engine/applyOperators.d.ts +3 -0
  74. package/dist/engine/applyOperators.js +23 -0
  75. package/dist/engine/context.d.ts +4 -0
  76. package/dist/engine/context.js +1 -0
  77. package/dist/engine/gamut.d.ts +13 -0
  78. package/dist/engine/gamut.js +101 -0
  79. package/dist/engine/gamut.test.d.ts +1 -0
  80. package/dist/engine/gamut.test.js +23 -0
  81. package/dist/engine/generateScale.d.ts +15 -0
  82. package/dist/engine/generateScale.js +29 -0
  83. package/dist/engine/generateScale.test.d.ts +1 -0
  84. package/dist/engine/generateScale.test.js +32 -0
  85. package/dist/engine/index.d.ts +8 -0
  86. package/dist/engine/index.js +4 -0
  87. package/dist/engine/normalize.d.ts +43 -0
  88. package/dist/engine/normalize.js +403 -0
  89. package/dist/engine/normalize.test.d.ts +1 -0
  90. package/dist/engine/normalize.test.js +136 -0
  91. package/dist/engine/onSolid.d.ts +3 -0
  92. package/dist/engine/onSolid.js +110 -0
  93. package/dist/engine/resolveBaseColor.d.ts +25 -0
  94. package/dist/engine/resolveBaseColor.js +127 -0
  95. package/dist/engine/resolveBaseColor.test.d.ts +1 -0
  96. package/dist/engine/resolveBaseColor.test.js +97 -0
  97. package/dist/export/__snapshots__/exportTheme.test.js.snap +74 -0
  98. package/dist/export/exportTheme.d.ts +47 -0
  99. package/dist/export/exportTheme.js +170 -0
  100. package/dist/export/exportTheme.test.d.ts +1 -0
  101. package/dist/export/exportTheme.test.js +118 -0
  102. package/dist/export/index.d.ts +1 -0
  103. package/dist/export/index.js +1 -0
  104. package/dist/export/serializeColor.d.ts +1 -0
  105. package/dist/export/serializeColor.js +1 -0
  106. package/dist/export/serializeColor.test.d.ts +1 -0
  107. package/dist/export/serializeColor.test.js +54 -0
  108. package/dist/export.d.ts +1 -0
  109. package/dist/export.js +1 -0
  110. package/dist/index.d.ts +3 -22
  111. package/dist/index.js +2 -18
  112. package/dist/operators/emphasis.d.ts +3 -0
  113. package/dist/operators/emphasis.js +113 -0
  114. package/dist/operators/emphasis.test.d.ts +1 -0
  115. package/dist/operators/emphasis.test.js +69 -0
  116. package/dist/operators/index.d.ts +3 -0
  117. package/dist/operators/index.js +2 -0
  118. package/dist/operators/state.d.ts +3 -0
  119. package/dist/operators/state.js +102 -0
  120. package/dist/operators/state.test.d.ts +1 -0
  121. package/dist/operators/state.test.js +48 -0
  122. package/dist/operators/types.d.ts +13 -0
  123. package/dist/operators/types.js +1 -0
  124. package/dist/operators/utils.d.ts +16 -0
  125. package/dist/operators/utils.js +23 -0
  126. package/dist/presets/curves.d.ts +28 -0
  127. package/dist/presets/curves.js +145 -0
  128. package/dist/presets/index.d.ts +2 -0
  129. package/dist/presets/index.js +1 -0
  130. package/dist/presets/tokens/index.d.ts +3 -0
  131. package/dist/presets/tokens/index.js +3 -0
  132. package/dist/presets/tokens/minimal-ui.d.ts +6 -0
  133. package/dist/presets/tokens/minimal-ui.js +53 -0
  134. package/dist/presets/tokens/modern-ui.d.ts +5 -0
  135. package/dist/presets/tokens/modern-ui.js +83 -0
  136. package/dist/presets/tokens/presets.test.d.ts +1 -0
  137. package/dist/presets/tokens/presets.test.js +31 -0
  138. package/dist/presets/tokens/radixLike-ui.d.ts +6 -0
  139. package/dist/presets/tokens/radixLike-ui.js +77 -0
  140. package/dist/serialize/index.d.ts +1 -0
  141. package/dist/serialize/index.js +1 -0
  142. package/dist/serialize/normalizeOutput.d.ts +6 -0
  143. package/dist/serialize/normalizeOutput.js +45 -0
  144. package/dist/serialize/serializeColor.d.ts +21 -0
  145. package/dist/serialize/serializeColor.js +178 -0
  146. package/dist/serialize/serializeResolved.test.d.ts +1 -0
  147. package/dist/serialize/serializeResolved.test.js +45 -0
  148. package/dist/serialize.d.ts +1 -0
  149. package/dist/serialize.js +1 -0
  150. package/dist/types/index.d.ts +187 -0
  151. package/dist/types/index.js +1 -0
  152. package/dist/utils/clamp.d.ts +1 -0
  153. package/dist/utils/clamp.js +1 -0
  154. package/dist/utils/index.d.ts +1 -0
  155. package/dist/utils/index.js +1 -0
  156. package/dist/utils/lerp.d.ts +1 -0
  157. package/dist/utils/lerp.js +1 -0
  158. package/dist/utils/parseColor.d.ts +6 -0
  159. package/dist/utils/parseColor.js +67 -0
  160. package/dist/utils/parseColor.test.d.ts +1 -0
  161. package/dist/utils/parseColor.test.js +51 -0
  162. package/dist/utils/smoothstep.d.ts +1 -0
  163. package/dist/utils/smoothstep.js +5 -0
  164. package/package.json +19 -12
  165. package/planning/phase-10-review.md +550 -0
  166. package/planning/phase-7-review.md +411 -0
  167. package/planning/phase-8-review.md +669 -0
  168. package/planning/phase-9-review.md +564 -0
  169. package/planning/roadmap-v0.3.md +284 -0
  170. package/planning/spec-serializer-v0.3.md +324 -0
  171. package/planning/spec-v0.3.md +305 -0
  172. package/src/cli/args.test.ts +28 -0
  173. package/src/cli/args.ts +66 -0
  174. package/src/cli/codegen/__snapshots__/tokens.test.ts.snap +87 -0
  175. package/src/cli/codegen/tokens.test.ts +61 -0
  176. package/src/cli/codegen/tokens.ts +191 -0
  177. package/src/cli/config.ts +71 -0
  178. package/src/cli/validate.test.ts +49 -0
  179. package/src/cli/validate.ts +38 -0
  180. package/src/cli.ts +183 -0
  181. package/src/contrast/apca.test.ts +20 -0
  182. package/src/contrast/apca.ts +26 -0
  183. package/src/contrast/index.ts +4 -0
  184. package/src/contrast/scoring.test.ts +188 -0
  185. package/src/contrast/scoring.ts +48 -0
  186. package/src/contrast/solver.test.ts +147 -0
  187. package/src/contrast/solver.ts +235 -0
  188. package/src/contrast/types.ts +20 -0
  189. package/src/contrast/utils.ts +28 -0
  190. package/src/contrast/wcag2.test.ts +21 -0
  191. package/src/contrast/wcag2.ts +24 -0
  192. package/src/core/createTheme.ts +78 -0
  193. package/src/core/dx-helpers.test.ts +82 -0
  194. package/src/core/index.ts +7 -0
  195. package/src/core/onSolid.test.ts +146 -0
  196. package/src/core/qa.v1.test.ts +149 -0
  197. package/src/core/resolve.test.ts +99 -0
  198. package/src/core/resolve.ts +11 -0
  199. package/src/core/resolveMany.ts +22 -0
  200. package/src/core/tokenRegistry.test.ts +153 -0
  201. package/src/core/tokenRegistry.ts +114 -0
  202. package/src/engine/applyOperators.ts +32 -0
  203. package/src/engine/context.ts +8 -0
  204. package/src/engine/gamut.test.ts +30 -0
  205. package/src/engine/gamut.ts +144 -0
  206. package/src/engine/generateScale.test.ts +46 -0
  207. package/src/engine/generateScale.ts +48 -0
  208. package/src/engine/index.ts +8 -0
  209. package/src/engine/normalize.test.ts +222 -0
  210. package/src/engine/normalize.ts +550 -0
  211. package/src/engine/onSolid.ts +178 -0
  212. package/src/engine/resolveBaseColor.test.ts +117 -0
  213. package/src/engine/resolveBaseColor.ts +203 -0
  214. package/src/export/__snapshots__/exportTheme.test.ts.snap +74 -0
  215. package/src/export/exportTheme.test.ts +144 -0
  216. package/src/export/exportTheme.ts +251 -0
  217. package/src/export/index.ts +1 -0
  218. package/src/export/serializeColor.test.ts +73 -0
  219. package/src/export/serializeColor.ts +1 -0
  220. package/src/export.ts +1 -0
  221. package/src/index.ts +3 -0
  222. package/src/operators/emphasis.test.ts +85 -0
  223. package/src/operators/emphasis.ts +132 -0
  224. package/src/operators/index.ts +3 -0
  225. package/src/operators/state.test.ts +66 -0
  226. package/src/operators/state.ts +122 -0
  227. package/src/operators/types.ts +14 -0
  228. package/src/operators/utils.ts +44 -0
  229. package/src/presets/curves.ts +168 -0
  230. package/src/presets/index.ts +2 -0
  231. package/src/presets/tokens/index.ts +3 -0
  232. package/src/presets/tokens/minimal-ui.ts +55 -0
  233. package/src/presets/tokens/modern-ui.ts +85 -0
  234. package/src/presets/tokens/presets.test.ts +46 -0
  235. package/src/presets/tokens/radixLike-ui.ts +79 -0
  236. package/src/serialize/index.ts +1 -0
  237. package/src/serialize/normalizeOutput.ts +63 -0
  238. package/src/serialize/serializeColor.ts +260 -0
  239. package/src/serialize/serializeResolved.test.ts +57 -0
  240. package/src/serialize.ts +1 -0
  241. package/src/types/index.ts +207 -0
  242. package/src/utils/clamp.ts +2 -0
  243. package/src/utils/index.ts +1 -0
  244. package/src/utils/lerp.ts +1 -0
  245. package/src/utils/parseColor.test.ts +66 -0
  246. package/src/utils/parseColor.ts +87 -0
  247. package/src/utils/smoothstep.ts +6 -0
  248. package/tsconfig.build.json +11 -0
  249. package/tsconfig.json +15 -0
  250. package/dist/alpha/generateAlphaScale.d.ts +0 -5
  251. package/dist/alpha/generateAlphaScale.js +0 -34
  252. package/dist/contrast/onSolid.d.ts +0 -6
  253. package/dist/contrast/onSolid.js +0 -28
  254. package/dist/contrast/solveText.d.ts +0 -2
  255. package/dist/contrast/solveText.js +0 -31
  256. package/dist/createTheme.d.ts +0 -38
  257. package/dist/createTheme.js +0 -148
  258. package/dist/data/radixSeeds.d.ts +0 -3
  259. package/dist/data/radixSeeds.js +0 -34
  260. package/dist/diagnostics/analyzeScale.d.ts +0 -2
  261. package/dist/diagnostics/analyzeScale.js +0 -7
  262. package/dist/diagnostics/analyzeTheme.d.ts +0 -2
  263. package/dist/diagnostics/analyzeTheme.js +0 -35
  264. package/dist/diagnostics/warnings.d.ts +0 -2
  265. package/dist/diagnostics/warnings.js +0 -20
  266. package/dist/engine/curves.d.ts +0 -9
  267. package/dist/engine/curves.js +0 -48
  268. package/dist/engine/oklch.d.ts +0 -8
  269. package/dist/engine/oklch.js +0 -40
  270. package/dist/engine/templates.d.ts +0 -14
  271. package/dist/engine/templates.js +0 -45
  272. package/dist/exporters/selectColorMode.d.ts +0 -2
  273. package/dist/exporters/selectColorMode.js +0 -19
  274. package/dist/exporters/toCssVars.d.ts +0 -13
  275. package/dist/exporters/toCssVars.js +0 -108
  276. package/dist/exporters/toJson.d.ts +0 -3
  277. package/dist/exporters/toJson.js +0 -25
  278. package/dist/exporters/toReactNative.d.ts +0 -54
  279. package/dist/exporters/toReactNative.js +0 -33
  280. package/dist/exporters/toTailwind.d.ts +0 -17
  281. package/dist/exporters/toTailwind.js +0 -111
  282. package/dist/exporters/toTs.d.ts +0 -3
  283. package/dist/exporters/toTs.js +0 -43
  284. package/dist/generateScale.d.ts +0 -48
  285. package/dist/generateScale.js +0 -274
  286. package/dist/overlays/generateOverlayScale.d.ts +0 -2
  287. package/dist/overlays/generateOverlayScale.js +0 -34
  288. package/dist/text/generateTextScale.d.ts +0 -8
  289. package/dist/text/generateTextScale.js +0 -18
  290. package/dist/text/resolveOnBgText.d.ts +0 -9
  291. package/dist/text/resolveOnBgText.js +0 -28
  292. package/dist/tokens/presetRadixLikeUi.d.ts +0 -5
  293. package/dist/tokens/presetRadixLikeUi.js +0 -55
  294. package/dist/types.d.ts +0 -69
  295. /package/dist/{types.js → cli/args.test.d.ts} +0 -0
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import * as apca from "../contrast/apca.js";
3
+ import { blendSrgb, toSrgbColor } from "../contrast/utils.js";
4
+ import * as operators from "../engine/applyOperators.js";
5
+ import { createTheme } from "./createTheme.js";
6
+ const passesApca = (fg, bg, targetLc, alpha = 1) => {
7
+ const fgSrgb = toSrgbColor(fg);
8
+ const bgSrgb = toSrgbColor(bg);
9
+ if (!fgSrgb || !bgSrgb) {
10
+ return false;
11
+ }
12
+ const composite = blendSrgb(fgSrgb, bgSrgb, alpha);
13
+ const value = Math.abs(apca.computeApcaLc(composite, bgSrgb));
14
+ return value >= targetLc;
15
+ };
16
+ const theme = createTheme({
17
+ seeds: {
18
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
19
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
20
+ },
21
+ preset: "modern",
22
+ });
23
+ describe("onSolid", () => {
24
+ it("chooses a white-ish foreground on dark solids", () => {
25
+ const bg = theme.resolve({
26
+ role: "action.primary",
27
+ usage: "bg",
28
+ surface: "solid",
29
+ context: "dark",
30
+ });
31
+ const result = theme.onSolid({
32
+ bgRole: "action.primary",
33
+ usage: "text",
34
+ context: "dark",
35
+ });
36
+ expect(bg.oklch.l).toBeLessThan(50);
37
+ expect(result.oklch.l).toBeGreaterThan(50);
38
+ expect(passesApca(result.oklch, bg.oklch, 75, result.oklch.alpha ?? 1)).toBe(true);
39
+ });
40
+ it("chooses a black-ish foreground on light solids", () => {
41
+ const bg = theme.resolve({
42
+ role: "action.primary",
43
+ usage: "bg",
44
+ surface: "solid",
45
+ context: "light",
46
+ });
47
+ const result = theme.onSolid({
48
+ bgRole: "action.primary",
49
+ usage: "icon",
50
+ context: "light",
51
+ });
52
+ expect(bg.oklch.l).toBeGreaterThanOrEqual(50);
53
+ expect(result.oklch.l).toBeLessThan(50);
54
+ expect(passesApca(result.oklch, bg.oklch, 60, result.oklch.alpha ?? 1)).toBe(true);
55
+ });
56
+ it("raises alpha to 1 when needed for contrast", () => {
57
+ const bg = theme.resolve({
58
+ role: "action.primary",
59
+ usage: "bg",
60
+ surface: "solid",
61
+ context: "dark",
62
+ });
63
+ const result = theme.onSolid({
64
+ bgRole: "action.primary",
65
+ usage: "icon",
66
+ context: "dark",
67
+ contrast: { model: "apca", targetLc: 90 },
68
+ });
69
+ expect(result.oklch.alpha).toBe(1);
70
+ expect(passesApca(result.oklch, bg.oklch, 90, 1)).toBe(true);
71
+ expect(passesApca(result.oklch, bg.oklch, 90, 0.72)).toBe(false);
72
+ });
73
+ it("throws in strict mode when contrast is impossible", () => {
74
+ expect(() => theme.onSolid({
75
+ bgRole: "action.primary",
76
+ usage: "text",
77
+ context: "light",
78
+ contrast: { model: "apca", targetLc: 140 },
79
+ output: { strict: true },
80
+ })).toThrow(/onSolid contrast failed|Contrast solver failed/i);
81
+ });
82
+ it("accepts results within epsilon for strict checks", () => {
83
+ const spy = vi.spyOn(apca, "computeApcaLc").mockImplementation(() => 74.995);
84
+ try {
85
+ expect(() => theme.onSolid({
86
+ bgRole: "action.primary",
87
+ usage: "text",
88
+ context: "light",
89
+ contrast: { model: "apca", targetLc: 75 },
90
+ output: { strict: true },
91
+ })).not.toThrow();
92
+ }
93
+ finally {
94
+ spy.mockRestore();
95
+ }
96
+ });
97
+ it("keeps alpha at 1 when mode is none", () => {
98
+ const result = theme.onSolid({
99
+ bgRole: "action.primary",
100
+ usage: "text",
101
+ context: "dark",
102
+ alpha: { mode: "none" },
103
+ });
104
+ expect(result.oklch.alpha).toBe(1);
105
+ });
106
+ it("applies state and emphasis to the background before solving", () => {
107
+ const spy = vi.spyOn(operators, "applyOperators");
108
+ theme.onSolid({
109
+ bgRole: "action.primary",
110
+ usage: "text",
111
+ context: "light",
112
+ state: "hover",
113
+ emphasis: "strong",
114
+ });
115
+ expect(spy).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ state: "hover", emphasis: "strong" }), expect.any(Object));
116
+ spy.mockRestore();
117
+ });
118
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { serializeColor } from "../export/serializeColor.js";
3
+ import { createTheme } from "./createTheme.js";
4
+ const buildTheme = () => createTheme({
5
+ seeds: {
6
+ light: { neutral: "#111827", accent: "#3d63dd" },
7
+ dark: { neutral: "#111827", accent: "#3d63dd" },
8
+ },
9
+ });
10
+ const toOklchString = (oklch) => serializeColor(oklch, { preferSpace: "oklch", includeMeta: true });
11
+ describe("QA v1", () => {
12
+ it("resolves light/dark background without NaNs", () => {
13
+ const theme = buildTheme();
14
+ const light = theme.resolve({
15
+ role: "bg.app",
16
+ usage: "bg",
17
+ context: "light",
18
+ surface: "app",
19
+ });
20
+ const dark = theme.resolve({
21
+ role: "bg.app",
22
+ usage: "bg",
23
+ context: "dark",
24
+ surface: "app",
25
+ });
26
+ const lightSerialized = toOklchString(light.oklch);
27
+ const darkSerialized = toOklchString(dark.oklch);
28
+ expect(typeof lightSerialized.value).toBe("string");
29
+ expect(lightSerialized.value.includes("oklch(")).toBe(true);
30
+ expect(Number.isFinite(lightSerialized.alpha)).toBe(true);
31
+ expect(lightSerialized.alpha).toBeGreaterThanOrEqual(0);
32
+ expect(lightSerialized.alpha).toBeLessThanOrEqual(1);
33
+ expect(typeof darkSerialized.value).toBe("string");
34
+ expect(darkSerialized.value.includes("oklch(")).toBe(true);
35
+ expect(Number.isFinite(darkSerialized.alpha)).toBe(true);
36
+ expect(darkSerialized.alpha).toBeGreaterThanOrEqual(0);
37
+ expect(darkSerialized.alpha).toBeLessThanOrEqual(1);
38
+ });
39
+ it("solves primary/secondary text on solid backgrounds", () => {
40
+ const theme = buildTheme();
41
+ const primary = theme.onSolid({
42
+ bgRole: "action.primary",
43
+ usage: "text",
44
+ context: "light",
45
+ contrast: { model: "apca", targetLc: 75 },
46
+ });
47
+ const secondary = theme.onSolid({
48
+ bgRole: "action.primary",
49
+ usage: "text",
50
+ context: "light",
51
+ contrast: { model: "apca", targetLc: 60 },
52
+ });
53
+ const primarySerialized = toOklchString(primary.oklch);
54
+ const secondarySerialized = toOklchString(secondary.oklch);
55
+ expect(primarySerialized.value).not.toBe("");
56
+ expect(primarySerialized.alpha).toBeGreaterThanOrEqual(0);
57
+ expect(primarySerialized.alpha).toBeLessThanOrEqual(1);
58
+ expect(secondarySerialized.value).not.toBe("");
59
+ expect(secondarySerialized.alpha).toBeGreaterThanOrEqual(0);
60
+ expect(secondarySerialized.alpha).toBeLessThanOrEqual(1);
61
+ });
62
+ it("handles solid button states and onSolid text", () => {
63
+ const theme = buildTheme();
64
+ const base = {
65
+ role: "action.primary",
66
+ usage: "bg",
67
+ surface: "solid",
68
+ context: "light",
69
+ };
70
+ const states = ["default", "hover", "active"];
71
+ const serializedStates = states.map((state) => toOklchString(theme.resolve({ ...base, state }).oklch).value);
72
+ serializedStates.forEach((value) => {
73
+ expect(value.startsWith("oklch(")).toBe(true);
74
+ });
75
+ const onSolidText = toOklchString(theme.onSolid({ bgRole: "action.primary", usage: "text", context: "light" }).oklch);
76
+ expect(onSolidText.alpha).toBeGreaterThanOrEqual(0.85);
77
+ expect(onSolidText.alpha).toBeLessThanOrEqual(1);
78
+ });
79
+ it("produces a distinct focus ring color with meta", () => {
80
+ const theme = buildTheme();
81
+ const ring = toOklchString(theme.resolve({
82
+ role: "focus.ring",
83
+ variant: "accent",
84
+ usage: "ring",
85
+ context: "light",
86
+ surface: "surface",
87
+ contrast: { model: "apca", targetLc: 45 },
88
+ }).oklch);
89
+ const surface = toOklchString(theme.resolve({
90
+ role: "surface.card",
91
+ usage: "bg",
92
+ context: "light",
93
+ surface: "surface",
94
+ }).oklch);
95
+ expect(ring.value).not.toBe(surface.value);
96
+ expect(ring.meta?.gamutMapping).toBeDefined();
97
+ });
98
+ it("keeps overlay scrim alpha stable", () => {
99
+ const theme = buildTheme();
100
+ const scrim = toOklchString(theme.resolve({
101
+ role: "overlay.scrim",
102
+ usage: "bg",
103
+ surface: "overlay",
104
+ context: "dark",
105
+ alpha: { mode: "fixed", alpha: 0.55 },
106
+ }).oklch);
107
+ expect(scrim.alpha).toBeGreaterThanOrEqual(0);
108
+ expect(scrim.alpha).toBeLessThanOrEqual(1);
109
+ expect(scrim.value.includes("oklch(")).toBe(true);
110
+ expect(scrim.value.includes("NaN")).toBe(false);
111
+ });
112
+ });
@@ -0,0 +1,3 @@
1
+ import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
2
+ import type { ColorQuery } from "../types/index.js";
3
+ export declare function resolve(query: ColorQuery, theme: ThemeConfig): BaseResolvedColor;
@@ -0,0 +1,8 @@
1
+ import { applyOperators } from "../engine/applyOperators.js";
2
+ import { normalizeQuery } from "../engine/normalize.js";
3
+ import { resolveBaseColor } from "../engine/resolveBaseColor.js";
4
+ export function resolve(query, theme) {
5
+ const normalized = normalizeQuery(query);
6
+ const base = resolveBaseColor(normalized, theme);
7
+ return applyOperators(base, normalized, theme);
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTheme } from "./createTheme.js";
3
+ const theme = createTheme({
4
+ seeds: {
5
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
6
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
7
+ },
8
+ preset: "modern",
9
+ });
10
+ describe("resolve", () => {
11
+ it("applies state operators via theme.resolve", () => {
12
+ const baseLight = theme.resolve({
13
+ role: "bg.solid",
14
+ usage: "bg",
15
+ surface: "solid",
16
+ context: "light",
17
+ });
18
+ const hoverLight = theme.resolve({
19
+ role: "bg.solid",
20
+ usage: "bg",
21
+ surface: "solid",
22
+ context: "light",
23
+ state: "hover",
24
+ });
25
+ const baseDark = theme.resolve({
26
+ role: "bg.solid",
27
+ usage: "bg",
28
+ surface: "solid",
29
+ context: "dark",
30
+ });
31
+ const hoverDark = theme.resolve({
32
+ role: "bg.solid",
33
+ usage: "bg",
34
+ surface: "solid",
35
+ context: "dark",
36
+ state: "hover",
37
+ });
38
+ const activeDark = theme.resolve({
39
+ role: "bg.solid",
40
+ usage: "bg",
41
+ surface: "solid",
42
+ context: "dark",
43
+ state: "active",
44
+ });
45
+ const activeLight = theme.resolve({
46
+ role: "bg.solid",
47
+ usage: "bg",
48
+ surface: "solid",
49
+ context: "light",
50
+ state: "active",
51
+ });
52
+ const disabledLight = theme.resolve({
53
+ role: "bg.solid",
54
+ usage: "bg",
55
+ surface: "solid",
56
+ context: "light",
57
+ state: "disabled",
58
+ });
59
+ const disabledDark = theme.resolve({
60
+ role: "bg.solid",
61
+ usage: "bg",
62
+ surface: "solid",
63
+ context: "dark",
64
+ state: "disabled",
65
+ });
66
+ expect(hoverLight.oklch.l).toBeLessThan(baseLight.oklch.l);
67
+ expect(hoverDark.oklch.l).toBeGreaterThan(baseDark.oklch.l);
68
+ expect(Math.abs(activeLight.oklch.l - baseLight.oklch.l)).toBeGreaterThan(Math.abs(hoverLight.oklch.l - baseLight.oklch.l));
69
+ expect(Math.abs(activeDark.oklch.l - baseDark.oklch.l)).toBeGreaterThan(Math.abs(hoverDark.oklch.l - baseDark.oklch.l));
70
+ expect(disabledLight.oklch.c).toBeLessThan(baseLight.oklch.c * 0.6);
71
+ expect(disabledDark.oklch.c).toBeLessThan(baseDark.oklch.c * 0.6);
72
+ });
73
+ it("applies emphasis via theme.resolve", () => {
74
+ const base = theme.resolve({
75
+ role: "text.primary",
76
+ usage: "text",
77
+ surface: "surface",
78
+ context: "light",
79
+ });
80
+ const muted = theme.resolve({
81
+ role: "text.primary",
82
+ usage: "text",
83
+ surface: "surface",
84
+ context: "light",
85
+ emphasis: "muted",
86
+ });
87
+ expect(muted.oklch.c).toBeLessThan(base.oklch.c);
88
+ });
89
+ });
@@ -0,0 +1,8 @@
1
+ import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
2
+ import type { ColorQuery } from "../types/index.js";
3
+ /**
4
+ * Resolve a batch of color queries while preserving input order.
5
+ *
6
+ * Convenience helper to resolve multiple color queries in a single call.
7
+ */
8
+ export declare function resolveMany(queries: ColorQuery[], theme: ThemeConfig): BaseResolvedColor[];
@@ -0,0 +1,17 @@
1
+ import { applyOperators } from "../engine/applyOperators.js";
2
+ import { normalizeQuery } from "../engine/normalize.js";
3
+ import { resolveBaseColor } from "../engine/resolveBaseColor.js";
4
+ /**
5
+ * Resolve a batch of color queries while preserving input order.
6
+ *
7
+ * Convenience helper to resolve multiple color queries in a single call.
8
+ */
9
+ export function resolveMany(queries, theme) {
10
+ const results = [];
11
+ for (const query of queries) {
12
+ const normalized = normalizeQuery(query);
13
+ const base = resolveBaseColor(normalized, theme);
14
+ results.push(applyOperators(base, normalized, theme));
15
+ }
16
+ return results;
17
+ }
@@ -0,0 +1,23 @@
1
+ import type { BaseResolvedColor } from "../engine/resolveBaseColor.js";
2
+ import type { TokenDefinition, TokenRegistry } from "../types/index.js";
3
+ import type { PaletteTheme } from "./createTheme.js";
4
+ /**
5
+ * Validate a token definition for safe registry usage.
6
+ *
7
+ * Guarantees (Phase 3):
8
+ * - registry stays declarative (no output options / no embedded color literals)
9
+ * - tokens are base tokens (interactive states declared via `token.states`)
10
+ */
11
+ export declare const validateTokenDefinition: (token: TokenDefinition) => void;
12
+ /**
13
+ * Validate a token registry and each token definition it contains.
14
+ */
15
+ export declare const validateTokenRegistry: (registry: TokenRegistry) => void;
16
+ /**
17
+ * Resolve a token definition through the provided theme.
18
+ */
19
+ export declare const resolveToken: (token: TokenDefinition, theme: PaletteTheme) => BaseResolvedColor;
20
+ /**
21
+ * Resolve all tokens in a registry while preserving key order.
22
+ */
23
+ export declare const resolveTokenRegistry: (registry: TokenRegistry, theme: PaletteTheme) => Record<string, BaseResolvedColor>;
@@ -0,0 +1,83 @@
1
+ import { COLOR_STATES, normalizeQuery } from "../engine/normalize.js";
2
+ const ALLOWED_TOKEN_STATES = COLOR_STATES.filter((state) => state !== "default");
3
+ const validateTokenStates = (states, name) => {
4
+ if (!states)
5
+ return;
6
+ for (const [state, enabled] of Object.entries(states)) {
7
+ if (enabled !== true) {
8
+ throw new Error(`Token "${name}" state "${state}" must be true`);
9
+ }
10
+ if (!ALLOWED_TOKEN_STATES.includes(state)) {
11
+ throw new Error(`Invalid token state "${state}" for "${name}"`);
12
+ }
13
+ }
14
+ };
15
+ /**
16
+ * Validate a token definition for safe registry usage.
17
+ *
18
+ * Guarantees (Phase 3):
19
+ * - registry stays declarative (no output options / no embedded color literals)
20
+ * - tokens are base tokens (interactive states declared via `token.states`)
21
+ */
22
+ export const validateTokenDefinition = (token) => {
23
+ if (typeof token.name !== "string" || !token.name.trim()) {
24
+ throw new Error("Token name is required");
25
+ }
26
+ if (typeof token.query?.role !== "string" || !token.query.role.trim()) {
27
+ throw new Error(`Token "${token.name}" requires a query role`);
28
+ }
29
+ if (!token.query.usage) {
30
+ throw new Error(`Token "${token.name}" requires a usage`);
31
+ }
32
+ if (!token.query.surface) {
33
+ throw new Error(`Token "${token.name}" requires a surface`);
34
+ }
35
+ const rawState = token.query.state;
36
+ const normalizedState = typeof rawState === "string" ? rawState.trim() : rawState;
37
+ if (normalizedState && normalizedState !== "default") {
38
+ throw new Error(`Token "${token.name}" must not encode state in query; declare supported states via token.states`);
39
+ }
40
+ const onKind = token.query.on?.kind;
41
+ if (onKind === "color") {
42
+ throw new Error(`Token "${token.name}" must not include a literal background color hint; use { kind: "role" } or { kind: "auto" }`);
43
+ }
44
+ if (token.query.output) {
45
+ throw new Error(`Token "${token.name}" must not include output options`);
46
+ }
47
+ validateTokenStates(token.states, token.name);
48
+ // Delegate to core validation for strict field validation.
49
+ normalizeQuery({ ...token.query, output: { strict: true } });
50
+ };
51
+ /**
52
+ * Validate a token registry and each token definition it contains.
53
+ */
54
+ export const validateTokenRegistry = (registry) => {
55
+ const entries = Object.entries(registry.tokens);
56
+ if (entries.length === 0) {
57
+ throw new Error("Token registry must include at least one token");
58
+ }
59
+ for (const [name, token] of entries) {
60
+ if (token.name !== name) {
61
+ throw new Error(`Token name mismatch: expected "${name}", got "${token.name}"`);
62
+ }
63
+ validateTokenDefinition(token);
64
+ }
65
+ };
66
+ /**
67
+ * Resolve a token definition through the provided theme.
68
+ */
69
+ export const resolveToken = (token, theme) => {
70
+ validateTokenDefinition(token);
71
+ return theme.resolve(token.query);
72
+ };
73
+ /**
74
+ * Resolve all tokens in a registry while preserving key order.
75
+ */
76
+ export const resolveTokenRegistry = (registry, theme) => {
77
+ validateTokenRegistry(registry);
78
+ const resolved = {};
79
+ for (const [name, token] of Object.entries(registry.tokens)) {
80
+ resolved[name] = theme.resolve(token.query);
81
+ }
82
+ return resolved;
83
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTheme } from "./createTheme.js";
3
+ import { resolveToken, resolveTokenRegistry, validateTokenRegistry } from "./tokenRegistry.js";
4
+ describe("token registry", () => {
5
+ const theme = createTheme({
6
+ seeds: {
7
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
8
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
9
+ },
10
+ preset: "modern",
11
+ });
12
+ it("validates tokens and resolves through theme.resolve", () => {
13
+ const registry = {
14
+ tokens: {
15
+ "bg.app": {
16
+ name: "bg.app",
17
+ description: "App background",
18
+ category: "background",
19
+ query: { role: "bg.app", usage: "bg", surface: "app" },
20
+ states: { hover: true },
21
+ },
22
+ },
23
+ };
24
+ validateTokenRegistry(registry);
25
+ const resolved = resolveTokenRegistry(registry, theme);
26
+ expect(resolved["bg.app"].step).toBeGreaterThan(0);
27
+ const single = resolveToken(registry.tokens["bg.app"], theme);
28
+ const expected = theme.resolve(registry.tokens["bg.app"].query);
29
+ expect(single.step).toBe(expected.step);
30
+ });
31
+ it("throws when required query fields are missing", () => {
32
+ const registry = {
33
+ tokens: {
34
+ "text.primary": {
35
+ name: "text.primary",
36
+ query: { role: "text.primary", usage: "text" },
37
+ },
38
+ },
39
+ };
40
+ expect(() => validateTokenRegistry(registry)).toThrow(/requires a surface/i);
41
+ });
42
+ it("throws when output options are provided", () => {
43
+ const registry = {
44
+ tokens: {
45
+ "bg.app": {
46
+ name: "bg.app",
47
+ query: {
48
+ role: "bg.app",
49
+ usage: "bg",
50
+ surface: "app",
51
+ output: { strict: true },
52
+ },
53
+ },
54
+ },
55
+ };
56
+ expect(() => validateTokenRegistry(registry)).toThrow(/must not include output/i);
57
+ });
58
+ it("treats whitespace-padded default state as default", () => {
59
+ const registry = {
60
+ tokens: {
61
+ "bg.app": {
62
+ name: "bg.app",
63
+ query: {
64
+ role: "bg.app",
65
+ usage: "bg",
66
+ surface: "app",
67
+ state: " default ",
68
+ },
69
+ },
70
+ },
71
+ };
72
+ expect(() => validateTokenRegistry(registry)).not.toThrow();
73
+ });
74
+ it("throws when token query encodes a non-default state", () => {
75
+ const registry = {
76
+ tokens: {
77
+ "bg.app": {
78
+ name: "bg.app",
79
+ query: { role: "bg.app", usage: "bg", surface: "app", state: "hover" },
80
+ },
81
+ },
82
+ };
83
+ expect(() => validateTokenRegistry(registry)).toThrow(/must not encode state/i);
84
+ });
85
+ it("throws when token query includes a literal background color hint", () => {
86
+ const registry = {
87
+ tokens: {
88
+ "bg.app": {
89
+ name: "bg.app",
90
+ query: {
91
+ role: "bg.app",
92
+ usage: "bg",
93
+ surface: "app",
94
+ on: { kind: "color", value: "#fff" },
95
+ },
96
+ },
97
+ },
98
+ };
99
+ expect(() => validateTokenRegistry(registry)).toThrow(/literal background color/i);
100
+ });
101
+ it("throws when token states include invalid keys or non-true values", () => {
102
+ const invalidKey = {
103
+ tokens: {
104
+ "bg.app": {
105
+ name: "bg.app",
106
+ query: { role: "bg.app", usage: "bg", surface: "app" },
107
+ states: { nope: true },
108
+ },
109
+ },
110
+ };
111
+ expect(() => validateTokenRegistry(invalidKey)).toThrow(/Invalid token state/i);
112
+ const includesDefault = {
113
+ tokens: {
114
+ "bg.app": {
115
+ name: "bg.app",
116
+ query: { role: "bg.app", usage: "bg", surface: "app" },
117
+ states: { default: true },
118
+ },
119
+ },
120
+ };
121
+ expect(() => validateTokenRegistry(includesDefault)).toThrow(/Invalid token state/i);
122
+ const nonTrueValue = {
123
+ tokens: {
124
+ "bg.app": {
125
+ name: "bg.app",
126
+ query: { role: "bg.app", usage: "bg", surface: "app" },
127
+ states: { hover: false },
128
+ },
129
+ },
130
+ };
131
+ expect(() => validateTokenRegistry(nonTrueValue)).toThrow(/must be true/i);
132
+ });
133
+ });
@@ -0,0 +1,3 @@
1
+ import type { NormalizedQuery } from "./normalize.js";
2
+ import type { BaseResolvedColor, ThemeConfig } from "./resolveBaseColor.js";
3
+ export declare const applyOperators: (base: BaseResolvedColor, normalized: NormalizedQuery, theme: ThemeConfig) => BaseResolvedColor;
@@ -0,0 +1,23 @@
1
+ import { applyEmphasisOperator } from "../operators/emphasis.js";
2
+ import { applyStateOperator } from "../operators/state.js";
3
+ import { mapColorContextToEngine } from "./context.js";
4
+ export const applyOperators = (base, normalized, theme) => {
5
+ const context = mapColorContextToEngine(normalized.context);
6
+ const operatorInput = {
7
+ oklch: base.oklch,
8
+ context,
9
+ surface: normalized.surface,
10
+ usage: normalized.usage,
11
+ state: normalized.state,
12
+ emphasis: normalized.emphasis,
13
+ preset: theme.preset,
14
+ step: base.step,
15
+ };
16
+ // Emphasis defines the baseline hierarchy; state applies transient interaction.
17
+ const emphasized = applyEmphasisOperator(operatorInput);
18
+ const stated = applyStateOperator({ ...operatorInput, oklch: emphasized });
19
+ return {
20
+ ...base,
21
+ oklch: stated,
22
+ };
23
+ };
@@ -0,0 +1,4 @@
1
+ import type { ColorContext } from "../types/index.js";
2
+ import type { NormalizedQuery } from "./normalize.js";
3
+ export type EngineContext = "light" | "dark";
4
+ export declare const mapColorContextToEngine: (context: ColorContext | NormalizedQuery["context"]) => EngineContext;
@@ -0,0 +1 @@
1
+ export const mapColorContextToEngine = (context) => (context === "dark" || context === "dimmed" ? "dark" : "light");
@@ -0,0 +1,13 @@
1
+ import type { OutputOptions } from "../types/index.js";
2
+ import type { OkLchColor } from "./generateScale.js";
3
+ export type GamutTarget = "srgb" | "p3";
4
+ export type GamutMapping = NonNullable<OutputOptions["gamutMapping"]>;
5
+ type GamutRgb = {
6
+ r: number;
7
+ g: number;
8
+ b: number;
9
+ };
10
+ export declare const isInGamut: (color: OkLchColor, target: GamutTarget) => boolean;
11
+ export declare const mapToGamut: (color: OkLchColor, target: GamutTarget, mapping: GamutMapping, strict: boolean) => OkLchColor;
12
+ export declare const toGamutRgb: (color: OkLchColor, target: GamutTarget) => GamutRgb | null;
13
+ export {};