@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,136 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { normalizeQuery } from "./normalize.js";
3
+ describe("normalizeQuery", () => {
4
+ it("applies defaults", () => {
5
+ const result = normalizeQuery({ role: "text.primary", surface: "surface" });
6
+ expect(result.usage).toBe("text");
7
+ expect(result.context).toBe("light");
8
+ expect(result.surface).toBe("surface");
9
+ expect(result.state).toBe("default");
10
+ expect(result.emphasis).toBe("default");
11
+ });
12
+ it("throws when role is missing", () => {
13
+ expect(() => normalizeQuery({})).toThrowError(/role/i);
14
+ });
15
+ it("applies output defaults", () => {
16
+ const result = normalizeQuery({ role: "text.primary", surface: "surface" });
17
+ expect(result.output.preferSpace).toBe("oklch");
18
+ expect(result.output.gamutMapping).toBe("preferP3ThenCompress");
19
+ expect(result.output.precision).toMatchObject({ l: 1, c: 3, h: 1 });
20
+ expect(result.output.strict).toBe(false);
21
+ expect(result.output.includeMeta).toBe(false);
22
+ });
23
+ it("infers usage from role prefixes", () => {
24
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
25
+ try {
26
+ expect(normalizeQuery({ role: "icon.primary" }).usage).toBe("icon");
27
+ expect(normalizeQuery({ role: "border.muted" }).usage).toBe("border");
28
+ expect(normalizeQuery({ role: "bg.canvas" }).usage).toBe("bg");
29
+ expect(normalizeQuery({ role: "ring.focus" }).usage).toBe("ring");
30
+ expect(normalizeQuery({ role: "chart.axis.stroke" }).usage).toBe("stroke");
31
+ expect(normalizeQuery({ role: "chart.fill.primary" }).usage).toBe("fill");
32
+ expect(normalizeQuery({ role: "chart.grid.muted" }).usage).toBe("border");
33
+ expect(normalizeQuery({ role: "chart.label" }).usage).toBe("text");
34
+ }
35
+ finally {
36
+ warnSpy.mockRestore();
37
+ }
38
+ });
39
+ it("infers surface when obvious", () => {
40
+ expect(normalizeQuery({ role: "bg.app" }).surface).toBe("app");
41
+ expect(normalizeQuery({ role: "bg.surface" }).surface).toBe("surface");
42
+ expect(normalizeQuery({ role: "app.bg", usage: "bg" }).surface).toBe("app");
43
+ });
44
+ it("infers variant from role token", () => {
45
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
46
+ try {
47
+ expect(normalizeQuery({ role: "success.bg", usage: "bg", surface: "surface" }).variant).toBe("success");
48
+ expect(normalizeQuery({ role: "warning.text", usage: "text", surface: "surface" }).variant).toBe("warning");
49
+ expect(normalizeQuery({ role: "danger.border", usage: "border", surface: "surface" }).variant).toBe("danger");
50
+ expect(normalizeQuery({ role: "category:sales.fill", usage: "fill", surface: "surface" }).variant).toBe("category:sales");
51
+ expect(normalizeQuery({
52
+ role: "chart:revenue.stroke",
53
+ usage: "stroke",
54
+ surface: "surface",
55
+ }).variant).toBe("chart:revenue");
56
+ expect(normalizeQuery({ role: "text.primary", usage: "text", surface: "surface" }).variant).toBeUndefined();
57
+ }
58
+ finally {
59
+ warnSpy.mockRestore();
60
+ }
61
+ });
62
+ it("requires usage when strict and inference fails", () => {
63
+ expect(() => normalizeQuery({ role: "brand.primary", output: { strict: true } })).toThrowError(/Provide usage explicitly/i);
64
+ });
65
+ it("warns when usage cannot be inferred in non-strict mode", () => {
66
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
67
+ expect(normalizeQuery({ role: "brand.primary" }).usage).toBe("bg");
68
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Defaulting usage to "bg"'));
69
+ warnSpy.mockRestore();
70
+ });
71
+ it("requires surface when strict and inference fails", () => {
72
+ expect(() => normalizeQuery({ role: "text.primary", output: { strict: true } })).toThrowError(/Provide surface explicitly/i);
73
+ });
74
+ it("warns when surface cannot be inferred in non-strict mode", () => {
75
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
76
+ expect(normalizeQuery({ role: "text.primary" }).surface).toBe("surface");
77
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Defaulting surface to "surface"'));
78
+ warnSpy.mockRestore();
79
+ });
80
+ it("validates background hint color values", () => {
81
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
82
+ expect(normalizeQuery({ role: "bg.canvas", surface: "surface", on: { kind: "color", value: "#fff" } })
83
+ .on).toEqual({ kind: "color", value: "#fff" });
84
+ expect(normalizeQuery({
85
+ role: "bg.canvas",
86
+ surface: "surface",
87
+ on: { kind: "color", value: "oklch(62% 0.18 265)" },
88
+ }).on).toEqual({ kind: "color", value: "oklch(62% 0.18 265)" });
89
+ expect(normalizeQuery({
90
+ role: "bg.canvas",
91
+ surface: "surface",
92
+ on: { kind: "color", value: "color(display-p3 1 0.5 0.25)" },
93
+ }).on).toEqual({ kind: "color", value: "color(display-p3 1 0.5 0.25)" });
94
+ expect(normalizeQuery({ role: "bg.canvas", surface: "surface", on: { kind: "color", value: "banana" } })
95
+ .on).toEqual({ kind: "color", value: "banana" });
96
+ expect(warnSpy).toHaveBeenCalledTimes(3);
97
+ warnSpy.mockRestore();
98
+ });
99
+ it("normalizes nested background hints", () => {
100
+ expect(normalizeQuery({
101
+ role: "bg.canvas",
102
+ surface: "surface",
103
+ on: { kind: "role", role: " text.primary " },
104
+ })
105
+ .on).toEqual({ kind: "role", role: "text.primary" });
106
+ expect(normalizeQuery({ role: "bg.canvas", surface: "surface", on: { kind: "auto" } }).on).toEqual({
107
+ kind: "auto",
108
+ });
109
+ expect(() => normalizeQuery({ role: "bg.canvas", surface: "surface", on: { kind: "color", value: " " } })).toThrowError(/color value is required/i);
110
+ expect(() => normalizeQuery({
111
+ role: "bg.canvas",
112
+ surface: "surface",
113
+ on: { kind: "color", value: "banana" },
114
+ output: { strict: true },
115
+ })).toThrowError(/background hint color value/i);
116
+ expect(() => normalizeQuery({ role: "bg.canvas", surface: "surface", on: { kind: "nope" } })).toThrowError(/background hint kind/i);
117
+ });
118
+ it("validates contrast requirements", () => {
119
+ expect(normalizeQuery({ role: "text.primary", contrast: { model: "apca", targetLc: 75 } })
120
+ .contrast).toEqual({ model: "apca", targetLc: 75 });
121
+ expect(normalizeQuery({ role: "text.primary", contrast: { model: "wcag2", minRatio: 4.5 } })
122
+ .contrast).toEqual({ model: "wcag2", minRatio: 4.5 });
123
+ expect(normalizeQuery({ role: "text.primary", contrast: { model: "none" } }).contrast).toEqual({ model: "none" });
124
+ expect(() => normalizeQuery({ role: "text.primary", contrast: { model: "apca" } })).toThrowError(/targetLc/i);
125
+ expect(() => normalizeQuery({ role: "text.primary", contrast: { model: "wcag2" } })).toThrowError(/minRatio.*ratio/i);
126
+ expect(() => normalizeQuery({ role: "text.primary", contrast: { model: "wcag2", minRatio: NaN } })).toThrowError(/minRatio must be a number/i);
127
+ expect(() => normalizeQuery({ role: "text.primary", contrast: { model: "nope" } })).toThrowError(/contrast model/i);
128
+ });
129
+ it("validates alpha strategies", () => {
130
+ expect(normalizeQuery({ role: "bg.canvas", alpha: { mode: "none" } }).alpha).toEqual({ mode: "none" });
131
+ expect(normalizeQuery({ role: "bg.canvas", alpha: { mode: "fixed", alpha: 0.5 } }).alpha).toEqual({ mode: "fixed", alpha: 0.5 });
132
+ expect(normalizeQuery({ role: "bg.canvas", alpha: { mode: "solveOnBackground" } }).alpha).toEqual({ mode: "solveOnBackground" });
133
+ expect(() => normalizeQuery({ role: "bg.canvas", alpha: { mode: "fixed" } })).toThrowError(/fixed alpha/i);
134
+ expect(() => normalizeQuery({ role: "bg.canvas", alpha: { mode: "nope" } })).toThrowError(/alpha strategy mode/i);
135
+ });
136
+ });
@@ -0,0 +1,3 @@
1
+ import type { OnSolidQuery } from "../types/index.js";
2
+ import type { BaseResolvedColor, ThemeConfig } from "./resolveBaseColor.js";
3
+ export declare function onSolid(query: OnSolidQuery, theme: ThemeConfig): BaseResolvedColor;
@@ -0,0 +1,110 @@
1
+ import { computeApcaLc } from "../contrast/apca.js";
2
+ import { solveContrast } from "../contrast/solver.js";
3
+ import { blendSrgb, toSrgbColor } from "../contrast/utils.js";
4
+ import { contrastRatio } from "../contrast/wcag2.js";
5
+ import { applyOperators } from "./applyOperators.js";
6
+ import { mapColorContextToEngine } from "./context.js";
7
+ import { normalizeOnSolidQuery, normalizeQuery } from "./normalize.js";
8
+ import { resolveBaseColor } from "./resolveBaseColor.js";
9
+ const DEFAULT_SOLVE_EPSILON = 0.01;
10
+ const nearWhite = { l: 97, c: 0, h: 0, alpha: 1 };
11
+ const nearBlack = { l: 15, c: 0, h: 0, alpha: 1 };
12
+ const defaultContrastForUsage = (usage) => ({
13
+ model: "apca",
14
+ targetLc: usage === "text" ? 75 : 60,
15
+ });
16
+ const validateFixedAlpha = (alpha) => {
17
+ if (alpha < 0 || alpha > 1) {
18
+ throw new Error("Fixed alpha must be between 0 and 1");
19
+ }
20
+ return alpha;
21
+ };
22
+ const resolveAlphaStrategy = (alpha, usage, strict) => {
23
+ if (!alpha) {
24
+ return { mode: "fixed", alpha: usage === "text" ? 0.92 : 0.72 };
25
+ }
26
+ if (alpha.mode === "none") {
27
+ return alpha;
28
+ }
29
+ if (alpha.mode === "fixed") {
30
+ return { mode: "fixed", alpha: validateFixedAlpha(alpha.alpha) };
31
+ }
32
+ if (strict) {
33
+ throw new Error('onSolid does not support alpha.mode "solveOnBackground"');
34
+ }
35
+ console.warn('onSolid does not support alpha.mode "solveOnBackground"; falling back to fixed defaults');
36
+ return { mode: "fixed", alpha: usage === "text" ? 0.92 : 0.72 };
37
+ };
38
+ const checkContrastWithAlpha = (fg, bg, req, alpha, epsilon = DEFAULT_SOLVE_EPSILON) => {
39
+ if (req.model === "none") {
40
+ return { pass: true, value: 0 };
41
+ }
42
+ const fgSrgb = toSrgbColor(fg);
43
+ const bgSrgb = toSrgbColor(bg);
44
+ if (!fgSrgb || !bgSrgb) {
45
+ return { pass: false, value: Number.NaN };
46
+ }
47
+ const composite = blendSrgb(fgSrgb, bgSrgb, alpha);
48
+ if (req.model === "apca") {
49
+ const value = Math.abs(computeApcaLc(composite, bgSrgb));
50
+ const minTarget = req.minLc ?? req.targetLc;
51
+ const maxTarget = req.maxLc ?? Number.POSITIVE_INFINITY;
52
+ return {
53
+ pass: Number.isFinite(value) && value >= minTarget - epsilon && value <= maxTarget + epsilon,
54
+ value,
55
+ };
56
+ }
57
+ const value = contrastRatio(composite, bgSrgb);
58
+ return { pass: Number.isFinite(value) && value + epsilon >= req.minRatio, value };
59
+ };
60
+ export function onSolid(query, theme) {
61
+ const normalized = normalizeOnSolidQuery(query);
62
+ const contrastRequirement = normalized.contrast ?? defaultContrastForUsage(normalized.usage);
63
+ const alphaStrategy = resolveAlphaStrategy(normalized.alpha, normalized.usage, normalized.output.strict);
64
+ const alpha = alphaStrategy.mode === "none" ? 1 : alphaStrategy.alpha;
65
+ const bgNormalized = normalizeQuery({
66
+ role: normalized.bgRole,
67
+ usage: "bg",
68
+ surface: "solid",
69
+ context: normalized.context,
70
+ state: normalized.state,
71
+ emphasis: normalized.emphasis,
72
+ });
73
+ const bgBase = resolveBaseColor(bgNormalized, theme);
74
+ // Apply state/emphasis operators to the background before onSolid solves.
75
+ const bgResolved = applyOperators(bgBase, bgNormalized, theme);
76
+ const bg = bgResolved.oklch;
77
+ const baseFg = bg.l >= 50 ? nearBlack : nearWhite;
78
+ const seedUsed = `oklch(${baseFg.l}% ${baseFg.c} ${baseFg.h})`;
79
+ const solverContext = {
80
+ preset: theme.preset,
81
+ surface: bgNormalized.surface,
82
+ context: mapColorContextToEngine(bgNormalized.context),
83
+ };
84
+ const solved = solveContrast({ ...baseFg, alpha }, bg, contrastRequirement, solverContext, {
85
+ strict: normalized.output.strict,
86
+ });
87
+ let finalAlpha = alpha;
88
+ let finalColor = solved.color;
89
+ let finalCheck = checkContrastWithAlpha(finalColor, bg, contrastRequirement, finalAlpha, DEFAULT_SOLVE_EPSILON);
90
+ if (!finalCheck.pass && finalAlpha < 1) {
91
+ finalAlpha = 1;
92
+ const solvedOpaque = solveContrast({ ...baseFg, alpha: 1 }, bg, contrastRequirement, solverContext, { strict: normalized.output.strict });
93
+ finalColor = solvedOpaque.color;
94
+ finalCheck = checkContrastWithAlpha(finalColor, bg, contrastRequirement, finalAlpha, DEFAULT_SOLVE_EPSILON);
95
+ }
96
+ if (!finalCheck.pass && normalized.output.strict) {
97
+ const target = contrastRequirement.model === "apca"
98
+ ? contrastRequirement.targetLc
99
+ : contrastRequirement.model === "wcag2"
100
+ ? contrastRequirement.minRatio
101
+ : 0;
102
+ throw new Error(`onSolid contrast failed (${contrastRequirement.model}) target=${target} value=${finalCheck.value}`);
103
+ }
104
+ return {
105
+ oklch: { ...finalColor, alpha: finalAlpha },
106
+ step: 0,
107
+ variantUsed: "onSolid",
108
+ seedUsed,
109
+ };
110
+ }
@@ -0,0 +1,25 @@
1
+ import type { CurvePresetName } from "../presets/index.js";
2
+ import type { ColorQuery, CssColorString } from "../types/index.js";
3
+ import { type OkLchColor } from "./generateScale.js";
4
+ import type { NormalizedQuery } from "./normalize.js";
5
+ type ThemeSeeds = {
6
+ neutral: CssColorString;
7
+ accent: CssColorString;
8
+ };
9
+ export type ThemeConfig = {
10
+ seeds: {
11
+ light: ThemeSeeds;
12
+ dark: ThemeSeeds;
13
+ };
14
+ variants?: Record<string, CssColorString>;
15
+ preset?: CurvePresetName;
16
+ };
17
+ export type BaseResolvedColor = {
18
+ oklch: OkLchColor;
19
+ step: number;
20
+ variantUsed: string;
21
+ seedUsed: CssColorString;
22
+ };
23
+ export declare function resolveBaseColor(query: ColorQuery, theme: ThemeConfig): BaseResolvedColor;
24
+ export declare function resolveBaseColor(normalized: NormalizedQuery, theme: ThemeConfig): BaseResolvedColor;
25
+ export {};
@@ -0,0 +1,127 @@
1
+ import { parseColor } from "../utils/parseColor.js";
2
+ import { mapColorContextToEngine } from "./context.js";
3
+ import { generateScale } from "./generateScale.js";
4
+ import { normalizeQuery } from "./normalize.js";
5
+ const isCategoryVariant = (variant) => variant.startsWith("category:");
6
+ const isChartVariant = (variant) => variant.startsWith("chart:");
7
+ const aliasVariants = new Set(["success", "warning", "danger", "info"]);
8
+ const inferVariantFromRole = (role) => {
9
+ const normalizedRole = role.toLowerCase();
10
+ if (normalizedRole.startsWith("action.")) {
11
+ return "accent";
12
+ }
13
+ if (normalizedRole.startsWith("bg.") ||
14
+ normalizedRole.startsWith("surface.") ||
15
+ normalizedRole.startsWith("border.") ||
16
+ normalizedRole.startsWith("text.")) {
17
+ return "neutral";
18
+ }
19
+ return "neutral";
20
+ };
21
+ const resolveVariantSeed = (variant, role, config, contextKey) => {
22
+ if (variant) {
23
+ if (variant === "neutral" || variant === "accent") {
24
+ return {
25
+ variantUsed: variant,
26
+ seedUsed: config.seeds[contextKey][variant],
27
+ };
28
+ }
29
+ if (isCategoryVariant(variant) || isChartVariant(variant)) {
30
+ const customSeed = config.variants?.[variant];
31
+ if (customSeed) {
32
+ return { variantUsed: variant, seedUsed: customSeed };
33
+ }
34
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
35
+ }
36
+ if (aliasVariants.has(variant)) {
37
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
38
+ }
39
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
40
+ }
41
+ const inferredVariant = inferVariantFromRole(role);
42
+ return {
43
+ variantUsed: inferredVariant,
44
+ seedUsed: config.seeds[contextKey][inferredVariant],
45
+ };
46
+ };
47
+ const clampStep = (value) => Math.min(12, Math.max(1, value));
48
+ const resolveStep = (usage, surface) => {
49
+ switch (usage) {
50
+ case "bg": {
51
+ switch (surface) {
52
+ case "app":
53
+ return 1;
54
+ case "surface":
55
+ return 2;
56
+ case "subtle":
57
+ return 3;
58
+ case "solid":
59
+ return 9;
60
+ case "overlay":
61
+ return 2;
62
+ case "data":
63
+ return 9;
64
+ case "transparent":
65
+ return 1;
66
+ default:
67
+ return 2;
68
+ }
69
+ }
70
+ case "border": {
71
+ switch (surface) {
72
+ case "solid":
73
+ case "data":
74
+ return 8;
75
+ default:
76
+ return 6;
77
+ }
78
+ }
79
+ case "text":
80
+ return 11;
81
+ case "icon":
82
+ return 11;
83
+ case "ring":
84
+ return 8;
85
+ case "stroke":
86
+ return surface === "data" ? 9 : 8;
87
+ case "fill":
88
+ return 9;
89
+ default:
90
+ return 6;
91
+ }
92
+ };
93
+ // Best-effort guard: ColorQuery may include state/emphasis already; a branded flag
94
+ // from normalizeQuery would be the strict approach in a future version.
95
+ const isNormalizedQuery = (value) => typeof value === "object" &&
96
+ value !== null &&
97
+ typeof value.role === "string" &&
98
+ typeof value.usage === "string" &&
99
+ typeof value.surface === "string" &&
100
+ typeof value.context === "string" &&
101
+ typeof value.state === "string" &&
102
+ typeof value.emphasis === "string";
103
+ export function resolveBaseColor(query, theme) {
104
+ const normalized = isNormalizedQuery(query) ? query : normalizeQuery(query);
105
+ const contextKey = mapColorContextToEngine(normalized.context);
106
+ const { variantUsed, seedUsed } = resolveVariantSeed(normalized.variant, normalized.role, theme, contextKey);
107
+ const parsedSeed = parseColor(seedUsed);
108
+ const seed = {
109
+ l: parsedSeed.okLch.channels[0] * 100,
110
+ c: parsedSeed.okLch.channels[1],
111
+ h: parsedSeed.okLch.channels[2],
112
+ alpha: parsedSeed.okLch.alpha,
113
+ };
114
+ const scale = generateScale(seed, {
115
+ context: contextKey,
116
+ surface: normalized.surface,
117
+ preset: theme.preset,
118
+ });
119
+ const step = clampStep(resolveStep(normalized.usage, normalized.surface));
120
+ const oklch = scale[step - 1];
121
+ return {
122
+ oklch,
123
+ step,
124
+ variantUsed,
125
+ seedUsed,
126
+ };
127
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseColor } from "../utils/parseColor.js";
3
+ import { normalizeQuery } from "./normalize.js";
4
+ import { resolveBaseColor } from "./resolveBaseColor.js";
5
+ const themeConfig = {
6
+ seeds: {
7
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
8
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
9
+ },
10
+ variants: {
11
+ "category:food": "#C2410C",
12
+ },
13
+ preset: "modern",
14
+ };
15
+ describe("resolveBaseColor", () => {
16
+ const r = (query) => resolveBaseColor(normalizeQuery(query), themeConfig);
17
+ it("defaults action roles to accent when variant is missing", () => {
18
+ const result = r({
19
+ role: "action.primary",
20
+ usage: "bg",
21
+ surface: "solid",
22
+ context: "light",
23
+ });
24
+ expect(result.variantUsed).toBe("accent");
25
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.accent);
26
+ });
27
+ it("defaults text roles to neutral when variant is missing", () => {
28
+ const result = r({
29
+ role: "text.primary",
30
+ usage: "text",
31
+ surface: "surface",
32
+ context: "light",
33
+ });
34
+ expect(result.variantUsed).toBe("neutral");
35
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.neutral);
36
+ });
37
+ it("uses custom category variants when provided", () => {
38
+ const result = r({
39
+ role: "bg.category",
40
+ variant: "category:food",
41
+ usage: "bg",
42
+ surface: "surface",
43
+ context: "light",
44
+ });
45
+ expect(result.variantUsed).toBe("category:food");
46
+ expect(result.seedUsed).toBe(themeConfig.variants?.["category:food"]);
47
+ });
48
+ it("falls back to accent for missing category variants", () => {
49
+ const result = r({
50
+ role: "bg.category",
51
+ variant: "category:missing",
52
+ usage: "bg",
53
+ surface: "surface",
54
+ context: "light",
55
+ });
56
+ expect(result.variantUsed).toBe("accent");
57
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.accent);
58
+ });
59
+ it("chooses steps based on usage and surface", () => {
60
+ const appBg = r({ role: "bg.app", usage: "bg", surface: "app", context: "light" });
61
+ const solidBg = r({ role: "bg.solid", usage: "bg", surface: "solid", context: "light" });
62
+ const borderSurface = r({
63
+ role: "border.surface",
64
+ usage: "border",
65
+ surface: "surface",
66
+ context: "light",
67
+ });
68
+ const ring = r({ role: "ring.focus", usage: "ring", surface: "surface", context: "light" });
69
+ expect(appBg.step).toBe(1);
70
+ expect(solidBg.step).toBe(9);
71
+ expect(borderSurface.step).toBe(6);
72
+ expect(ring.step).toBe(8);
73
+ });
74
+ it("returns valid OKLCH values with stable hue", () => {
75
+ const result = r({
76
+ role: "action.primary",
77
+ usage: "bg",
78
+ surface: "solid",
79
+ context: "light",
80
+ });
81
+ const seed = parseColor(themeConfig.seeds.light.accent);
82
+ expect(result.oklch.l).toBeGreaterThanOrEqual(0);
83
+ expect(result.oklch.l).toBeLessThanOrEqual(100);
84
+ expect(result.oklch.c).toBeGreaterThanOrEqual(0);
85
+ expect(result.oklch.h).toBeCloseTo(seed.okLch.channels[2], 6);
86
+ });
87
+ it("accepts normalized queries without re-normalizing", () => {
88
+ const normalized = normalizeQuery({
89
+ role: "text.secondary",
90
+ usage: "text",
91
+ surface: "surface",
92
+ context: "dark",
93
+ });
94
+ const result = resolveBaseColor(normalized, themeConfig);
95
+ expect(result.step).toBe(11);
96
+ });
97
+ });
@@ -0,0 +1,74 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`exportTheme > exports deterministic CSS with @supports overrides 1`] = `
4
+ ":root {
5
+ --pk-bg-app: #dddee1;
6
+ --pk-custom-alias: #f1f4fb;
7
+ --pk-text-primary: #f1f4fb;
8
+ }
9
+
10
+ @supports (color: oklch(0% 0 0)) {
11
+ :root {
12
+ --pk-bg-app: oklch(90% 0.004 264.7);
13
+ --pk-bg-app-oklch: oklch(90% 0.004 264.7);
14
+ --pk-custom-alias: oklch(96.7% 0.01 264.7);
15
+ --pk-custom-alias-oklch: oklch(96.7% 0.01 264.7);
16
+ --pk-text-primary: oklch(96.7% 0.01 264.7);
17
+ --pk-text-primary-oklch: oklch(96.7% 0.01 264.7);
18
+ }
19
+ }
20
+
21
+ @supports (color: color(display-p3 1 1 1)) {
22
+ :root {
23
+ --pk-bg-app-p3: color(display-p3 0.866 0.87 0.88);
24
+ --pk-custom-alias-p3: color(display-p3 0.947 0.957 0.981);
25
+ --pk-text-primary-p3: color(display-p3 0.947 0.957 0.981);
26
+ }
27
+ }
28
+ "
29
+ `;
30
+
31
+ exports[`exportTheme > exports deterministic JSON with light/dark contexts 1`] = `
32
+ {
33
+ "dark": {
34
+ "bg.app": {
35
+ "alpha": 1,
36
+ "oklch": "oklch(6% 0.004 264.7)",
37
+ "srgb": "#010101",
38
+ "value": "oklch(6% 0.004 264.7)",
39
+ },
40
+ "custom.alias": {
41
+ "alpha": 1,
42
+ "oklch": "oklch(27.5% 0.01 264.7)",
43
+ "srgb": "#25282d",
44
+ "value": "oklch(27.5% 0.01 264.7)",
45
+ },
46
+ "text.primary": {
47
+ "alpha": 1,
48
+ "oklch": "oklch(27.5% 0.01 264.7)",
49
+ "srgb": "#25282d",
50
+ "value": "oklch(27.5% 0.01 264.7)",
51
+ },
52
+ },
53
+ "light": {
54
+ "bg.app": {
55
+ "alpha": 1,
56
+ "oklch": "oklch(90% 0.004 264.7)",
57
+ "srgb": "#dddee1",
58
+ "value": "oklch(90% 0.004 264.7)",
59
+ },
60
+ "custom.alias": {
61
+ "alpha": 1,
62
+ "oklch": "oklch(96.7% 0.01 264.7)",
63
+ "srgb": "#f1f4fb",
64
+ "value": "oklch(96.7% 0.01 264.7)",
65
+ },
66
+ "text.primary": {
67
+ "alpha": 1,
68
+ "oklch": "oklch(96.7% 0.01 264.7)",
69
+ "srgb": "#f1f4fb",
70
+ "value": "oklch(96.7% 0.01 264.7)",
71
+ },
72
+ },
73
+ }
74
+ `;
@@ -0,0 +1,47 @@
1
+ import type { OkLchColor } from "../engine/generateScale.js";
2
+ import type { ColorMeta, ColorQuery, OutputOptions } from "../types/index.js";
3
+ export type ThemeToken = Omit<ColorQuery, "role" | "output"> & {
4
+ role?: string;
5
+ };
6
+ export type ThemeTokens = Record<string, ThemeToken>;
7
+ export type ExportableTheme = {
8
+ resolve: (query: ColorQuery) => {
9
+ oklch: OkLchColor;
10
+ };
11
+ };
12
+ export type TokenValue = {
13
+ value: string;
14
+ srgb?: string;
15
+ p3?: string;
16
+ oklch?: string;
17
+ alpha: number;
18
+ meta?: ColorMeta;
19
+ };
20
+ export type ExportMeta = {
21
+ gamutMapping: NonNullable<OutputOptions["gamutMapping"]>;
22
+ preferSpace: NonNullable<OutputOptions["preferSpace"]>;
23
+ includeSpaces: NonNullable<OutputOptions["includeSpaces"]>;
24
+ precision: Required<NonNullable<OutputOptions["precision"]>>;
25
+ srgbFormat: NonNullable<OutputOptions["srgbFormat"]>;
26
+ strict: boolean;
27
+ };
28
+ /**
29
+ * Export CSS variables for a token map with progressive fallbacks.
30
+ *
31
+ * - Base output uses sRGB for maximum compatibility.
32
+ * - @supports blocks override the main token value with OKLCH and/or P3 as requested.
33
+ */
34
+ export declare const exportThemeCss: (theme: ExportableTheme, tokens: ThemeTokens, output?: OutputOptions) => {
35
+ css: string;
36
+ meta: ExportMeta | undefined;
37
+ };
38
+ /**
39
+ * Export JSON tokens for both light and dark contexts.
40
+ */
41
+ export declare const exportThemeJson: (theme: ExportableTheme, tokens: ThemeTokens, output?: OutputOptions) => {
42
+ tokens: {
43
+ light: Record<string, TokenValue>;
44
+ dark: Record<string, TokenValue>;
45
+ };
46
+ meta: ExportMeta | undefined;
47
+ };