@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,178 @@
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 type { ContrastRequirement, OnSolidQuery } from "../types/index.js";
6
+ import { applyOperators } from "./applyOperators.js";
7
+ import { mapColorContextToEngine } from "./context.js";
8
+ import type { OkLchColor } from "./generateScale.js";
9
+ import { normalizeOnSolidQuery, normalizeQuery } from "./normalize.js";
10
+ import type { BaseResolvedColor, ThemeConfig } from "./resolveBaseColor.js";
11
+ import { resolveBaseColor } from "./resolveBaseColor.js";
12
+
13
+ const DEFAULT_SOLVE_EPSILON = 0.01;
14
+
15
+ const nearWhite: OkLchColor = { l: 97, c: 0, h: 0, alpha: 1 };
16
+ const nearBlack: OkLchColor = { l: 15, c: 0, h: 0, alpha: 1 };
17
+
18
+ const defaultContrastForUsage = (usage: OnSolidQuery["usage"]): ContrastRequirement => ({
19
+ model: "apca",
20
+ targetLc: usage === "text" ? 75 : 60,
21
+ });
22
+
23
+ const validateFixedAlpha = (alpha: number) => {
24
+ if (alpha < 0 || alpha > 1) {
25
+ throw new Error("Fixed alpha must be between 0 and 1");
26
+ }
27
+
28
+ return alpha;
29
+ };
30
+
31
+ const resolveAlphaStrategy = (
32
+ alpha: OnSolidQuery["alpha"] | undefined,
33
+ usage: OnSolidQuery["usage"],
34
+ strict: boolean,
35
+ ): { mode: "none" } | { mode: "fixed"; alpha: number } => {
36
+ if (!alpha) {
37
+ return { mode: "fixed", alpha: usage === "text" ? 0.92 : 0.72 };
38
+ }
39
+
40
+ if (alpha.mode === "none") {
41
+ return alpha;
42
+ }
43
+
44
+ if (alpha.mode === "fixed") {
45
+ return { mode: "fixed", alpha: validateFixedAlpha(alpha.alpha) };
46
+ }
47
+
48
+ if (strict) {
49
+ throw new Error('onSolid does not support alpha.mode "solveOnBackground"');
50
+ }
51
+
52
+ console.warn(
53
+ 'onSolid does not support alpha.mode "solveOnBackground"; falling back to fixed defaults',
54
+ );
55
+ return { mode: "fixed", alpha: usage === "text" ? 0.92 : 0.72 };
56
+ };
57
+
58
+ const checkContrastWithAlpha = (
59
+ fg: OkLchColor,
60
+ bg: OkLchColor,
61
+ req: ContrastRequirement,
62
+ alpha: number,
63
+ epsilon = DEFAULT_SOLVE_EPSILON,
64
+ ): { pass: boolean; value: number } => {
65
+ if (req.model === "none") {
66
+ return { pass: true, value: 0 };
67
+ }
68
+
69
+ const fgSrgb = toSrgbColor(fg);
70
+ const bgSrgb = toSrgbColor(bg);
71
+
72
+ if (!fgSrgb || !bgSrgb) {
73
+ return { pass: false, value: Number.NaN };
74
+ }
75
+
76
+ const composite = blendSrgb(fgSrgb, bgSrgb, alpha);
77
+
78
+ if (req.model === "apca") {
79
+ const value = Math.abs(computeApcaLc(composite, bgSrgb));
80
+ const minTarget = req.minLc ?? req.targetLc;
81
+ const maxTarget = req.maxLc ?? Number.POSITIVE_INFINITY;
82
+ return {
83
+ pass: Number.isFinite(value) && value >= minTarget - epsilon && value <= maxTarget + epsilon,
84
+ value,
85
+ };
86
+ }
87
+
88
+ const value = contrastRatio(composite, bgSrgb);
89
+ return { pass: Number.isFinite(value) && value + epsilon >= req.minRatio, value };
90
+ };
91
+
92
+ export function onSolid(query: OnSolidQuery, theme: ThemeConfig): BaseResolvedColor {
93
+ const normalized = normalizeOnSolidQuery(query);
94
+ const contrastRequirement = normalized.contrast ?? defaultContrastForUsage(normalized.usage);
95
+ const alphaStrategy = resolveAlphaStrategy(
96
+ normalized.alpha,
97
+ normalized.usage,
98
+ normalized.output.strict,
99
+ );
100
+ const alpha = alphaStrategy.mode === "none" ? 1 : alphaStrategy.alpha;
101
+
102
+ const bgNormalized = normalizeQuery({
103
+ role: normalized.bgRole,
104
+ usage: "bg",
105
+ surface: "solid",
106
+ context: normalized.context,
107
+ state: normalized.state,
108
+ emphasis: normalized.emphasis,
109
+ });
110
+ const bgBase = resolveBaseColor(bgNormalized, theme);
111
+ // Apply state/emphasis operators to the background before onSolid solves.
112
+ const bgResolved = applyOperators(bgBase, bgNormalized, theme);
113
+ const bg = bgResolved.oklch;
114
+
115
+ const baseFg = bg.l >= 50 ? nearBlack : nearWhite;
116
+ const seedUsed = `oklch(${baseFg.l}% ${baseFg.c} ${baseFg.h})`;
117
+
118
+ const solverContext = {
119
+ preset: theme.preset,
120
+ surface: bgNormalized.surface,
121
+ context: mapColorContextToEngine(bgNormalized.context),
122
+ };
123
+
124
+ const solved = solveContrast({ ...baseFg, alpha }, bg, contrastRequirement, solverContext, {
125
+ strict: normalized.output.strict,
126
+ });
127
+
128
+ let finalAlpha = alpha;
129
+ let finalColor = solved.color;
130
+ let finalCheck = checkContrastWithAlpha(
131
+ finalColor,
132
+ bg,
133
+ contrastRequirement,
134
+ finalAlpha,
135
+ DEFAULT_SOLVE_EPSILON,
136
+ );
137
+
138
+ if (!finalCheck.pass && finalAlpha < 1) {
139
+ finalAlpha = 1;
140
+ const solvedOpaque = solveContrast(
141
+ { ...baseFg, alpha: 1 },
142
+ bg,
143
+ contrastRequirement,
144
+ solverContext,
145
+ { strict: normalized.output.strict },
146
+ );
147
+
148
+ finalColor = solvedOpaque.color;
149
+ finalCheck = checkContrastWithAlpha(
150
+ finalColor,
151
+ bg,
152
+ contrastRequirement,
153
+ finalAlpha,
154
+ DEFAULT_SOLVE_EPSILON,
155
+ );
156
+ }
157
+
158
+ if (!finalCheck.pass && normalized.output.strict) {
159
+ const target =
160
+ contrastRequirement.model === "apca"
161
+ ? contrastRequirement.targetLc
162
+ : contrastRequirement.model === "wcag2"
163
+ ? contrastRequirement.minRatio
164
+ : 0;
165
+ throw new Error(
166
+ `onSolid contrast failed (${contrastRequirement.model}) target=${
167
+ target
168
+ } value=${finalCheck.value}`,
169
+ );
170
+ }
171
+
172
+ return {
173
+ oklch: { ...finalColor, alpha: finalAlpha },
174
+ step: 0,
175
+ variantUsed: "onSolid",
176
+ seedUsed,
177
+ };
178
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseColor } from "../utils/parseColor.js";
4
+ import type { ColorQuery } from "../types/index.js";
5
+ import { normalizeQuery } from "./normalize.js";
6
+ import { resolveBaseColor } from "./resolveBaseColor.js";
7
+ import type { ThemeConfig } from "./resolveBaseColor.js";
8
+
9
+ const themeConfig: ThemeConfig = {
10
+ seeds: {
11
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
12
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
13
+ },
14
+ variants: {
15
+ "category:food": "#C2410C",
16
+ },
17
+ preset: "modern",
18
+ };
19
+
20
+ describe("resolveBaseColor", () => {
21
+ const r = (query: ColorQuery) => resolveBaseColor(normalizeQuery(query), themeConfig);
22
+
23
+ it("defaults action roles to accent when variant is missing", () => {
24
+ const result = r({
25
+ role: "action.primary",
26
+ usage: "bg",
27
+ surface: "solid",
28
+ context: "light",
29
+ });
30
+
31
+ expect(result.variantUsed).toBe("accent");
32
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.accent);
33
+ });
34
+
35
+ it("defaults text roles to neutral when variant is missing", () => {
36
+ const result = r({
37
+ role: "text.primary",
38
+ usage: "text",
39
+ surface: "surface",
40
+ context: "light",
41
+ });
42
+
43
+ expect(result.variantUsed).toBe("neutral");
44
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.neutral);
45
+ });
46
+
47
+ it("uses custom category variants when provided", () => {
48
+ const result = r({
49
+ role: "bg.category",
50
+ variant: "category:food",
51
+ usage: "bg",
52
+ surface: "surface",
53
+ context: "light",
54
+ });
55
+
56
+ expect(result.variantUsed).toBe("category:food");
57
+ expect(result.seedUsed).toBe(themeConfig.variants?.["category:food"]);
58
+ });
59
+
60
+ it("falls back to accent for missing category variants", () => {
61
+ const result = r({
62
+ role: "bg.category",
63
+ variant: "category:missing",
64
+ usage: "bg",
65
+ surface: "surface",
66
+ context: "light",
67
+ });
68
+
69
+ expect(result.variantUsed).toBe("accent");
70
+ expect(result.seedUsed).toBe(themeConfig.seeds.light.accent);
71
+ });
72
+
73
+ it("chooses steps based on usage and surface", () => {
74
+ const appBg = r({ role: "bg.app", usage: "bg", surface: "app", context: "light" });
75
+ const solidBg = r({ role: "bg.solid", usage: "bg", surface: "solid", context: "light" });
76
+ const borderSurface = r({
77
+ role: "border.surface",
78
+ usage: "border",
79
+ surface: "surface",
80
+ context: "light",
81
+ });
82
+ const ring = r({ role: "ring.focus", usage: "ring", surface: "surface", context: "light" });
83
+
84
+ expect(appBg.step).toBe(1);
85
+ expect(solidBg.step).toBe(9);
86
+ expect(borderSurface.step).toBe(6);
87
+ expect(ring.step).toBe(8);
88
+ });
89
+
90
+ it("returns valid OKLCH values with stable hue", () => {
91
+ const result = r({
92
+ role: "action.primary",
93
+ usage: "bg",
94
+ surface: "solid",
95
+ context: "light",
96
+ });
97
+ const seed = parseColor(themeConfig.seeds.light.accent);
98
+
99
+ expect(result.oklch.l).toBeGreaterThanOrEqual(0);
100
+ expect(result.oklch.l).toBeLessThanOrEqual(100);
101
+ expect(result.oklch.c).toBeGreaterThanOrEqual(0);
102
+ expect(result.oklch.h).toBeCloseTo(seed.okLch.channels[2], 6);
103
+ });
104
+
105
+ it("accepts normalized queries without re-normalizing", () => {
106
+ const normalized = normalizeQuery({
107
+ role: "text.secondary",
108
+ usage: "text",
109
+ surface: "surface",
110
+ context: "dark",
111
+ });
112
+
113
+ const result = resolveBaseColor(normalized, themeConfig);
114
+
115
+ expect(result.step).toBe(11);
116
+ });
117
+ });
@@ -0,0 +1,203 @@
1
+ import type { CurvePresetName } from "../presets/index.js";
2
+ import type {
3
+ ColorQuery,
4
+ ColorUsage,
5
+ CssColorString,
6
+ SemanticVariant,
7
+ SurfaceIntent,
8
+ } from "../types/index.js";
9
+ import { parseColor } from "../utils/parseColor.js";
10
+ import { mapColorContextToEngine } from "./context.js";
11
+ import { generateScale, type OkLchColor } from "./generateScale.js";
12
+ import type { NormalizedQuery } from "./normalize.js";
13
+ import { normalizeQuery } from "./normalize.js";
14
+
15
+ type ThemeSeeds = {
16
+ neutral: CssColorString;
17
+ accent: CssColorString;
18
+ };
19
+
20
+ export type ThemeConfig = {
21
+ seeds: {
22
+ light: ThemeSeeds;
23
+ dark: ThemeSeeds;
24
+ };
25
+ variants?: Record<string, CssColorString>;
26
+ preset?: CurvePresetName;
27
+ };
28
+
29
+ type VariantResolution = {
30
+ variantUsed: string;
31
+ seedUsed: CssColorString;
32
+ };
33
+
34
+ const isCategoryVariant = (variant: string) => variant.startsWith("category:");
35
+ const isChartVariant = (variant: string) => variant.startsWith("chart:");
36
+
37
+ const aliasVariants = new Set<SemanticVariant>(["success", "warning", "danger", "info"]);
38
+
39
+ const inferVariantFromRole = (role: string): "neutral" | "accent" => {
40
+ const normalizedRole = role.toLowerCase();
41
+
42
+ if (normalizedRole.startsWith("action.")) {
43
+ return "accent";
44
+ }
45
+
46
+ if (
47
+ normalizedRole.startsWith("bg.") ||
48
+ normalizedRole.startsWith("surface.") ||
49
+ normalizedRole.startsWith("border.") ||
50
+ normalizedRole.startsWith("text.")
51
+ ) {
52
+ return "neutral";
53
+ }
54
+
55
+ return "neutral";
56
+ };
57
+
58
+ const resolveVariantSeed = (
59
+ variant: SemanticVariant | undefined,
60
+ role: string,
61
+ config: ThemeConfig,
62
+ contextKey: "light" | "dark",
63
+ ): VariantResolution => {
64
+ if (variant) {
65
+ if (variant === "neutral" || variant === "accent") {
66
+ return {
67
+ variantUsed: variant,
68
+ seedUsed: config.seeds[contextKey][variant],
69
+ };
70
+ }
71
+
72
+ if (isCategoryVariant(variant) || isChartVariant(variant)) {
73
+ const customSeed = config.variants?.[variant];
74
+ if (customSeed) {
75
+ return { variantUsed: variant, seedUsed: customSeed };
76
+ }
77
+
78
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
79
+ }
80
+
81
+ if (aliasVariants.has(variant)) {
82
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
83
+ }
84
+
85
+ return { variantUsed: "accent", seedUsed: config.seeds[contextKey].accent };
86
+ }
87
+
88
+ const inferredVariant = inferVariantFromRole(role);
89
+
90
+ return {
91
+ variantUsed: inferredVariant,
92
+ seedUsed: config.seeds[contextKey][inferredVariant],
93
+ };
94
+ };
95
+
96
+ const clampStep = (value: number) => Math.min(12, Math.max(1, value));
97
+
98
+ const resolveStep = (usage: ColorUsage, surface: SurfaceIntent): number => {
99
+ switch (usage) {
100
+ case "bg": {
101
+ switch (surface) {
102
+ case "app":
103
+ return 1;
104
+ case "surface":
105
+ return 2;
106
+ case "subtle":
107
+ return 3;
108
+ case "solid":
109
+ return 9;
110
+ case "overlay":
111
+ return 2;
112
+ case "data":
113
+ return 9;
114
+ case "transparent":
115
+ return 1;
116
+ default:
117
+ return 2;
118
+ }
119
+ }
120
+ case "border": {
121
+ switch (surface) {
122
+ case "solid":
123
+ case "data":
124
+ return 8;
125
+ default:
126
+ return 6;
127
+ }
128
+ }
129
+ case "text":
130
+ return 11;
131
+ case "icon":
132
+ return 11;
133
+ case "ring":
134
+ return 8;
135
+ case "stroke":
136
+ return surface === "data" ? 9 : 8;
137
+ case "fill":
138
+ return 9;
139
+ default:
140
+ return 6;
141
+ }
142
+ };
143
+
144
+ export type BaseResolvedColor = {
145
+ oklch: OkLchColor;
146
+ step: number;
147
+ variantUsed: string;
148
+ seedUsed: CssColorString;
149
+ };
150
+
151
+ // Best-effort guard: ColorQuery may include state/emphasis already; a branded flag
152
+ // from normalizeQuery would be the strict approach in a future version.
153
+ const isNormalizedQuery = (value: ColorQuery | NormalizedQuery): value is NormalizedQuery =>
154
+ typeof value === "object" &&
155
+ value !== null &&
156
+ typeof value.role === "string" &&
157
+ typeof value.usage === "string" &&
158
+ typeof value.surface === "string" &&
159
+ typeof value.context === "string" &&
160
+ typeof value.state === "string" &&
161
+ typeof value.emphasis === "string";
162
+
163
+ export function resolveBaseColor(query: ColorQuery, theme: ThemeConfig): BaseResolvedColor;
164
+ export function resolveBaseColor(
165
+ normalized: NormalizedQuery,
166
+ theme: ThemeConfig,
167
+ ): BaseResolvedColor;
168
+ export function resolveBaseColor(
169
+ query: ColorQuery | NormalizedQuery,
170
+ theme: ThemeConfig,
171
+ ): BaseResolvedColor {
172
+ const normalized = isNormalizedQuery(query) ? query : normalizeQuery(query);
173
+ const contextKey = mapColorContextToEngine(normalized.context);
174
+ const { variantUsed, seedUsed } = resolveVariantSeed(
175
+ normalized.variant,
176
+ normalized.role,
177
+ theme,
178
+ contextKey,
179
+ );
180
+
181
+ const parsedSeed = parseColor(seedUsed);
182
+ const seed: OkLchColor = {
183
+ l: parsedSeed.okLch.channels[0] * 100,
184
+ c: parsedSeed.okLch.channels[1],
185
+ h: parsedSeed.okLch.channels[2],
186
+ alpha: parsedSeed.okLch.alpha,
187
+ };
188
+
189
+ const scale = generateScale(seed, {
190
+ context: contextKey,
191
+ surface: normalized.surface,
192
+ preset: theme.preset,
193
+ });
194
+ const step = clampStep(resolveStep(normalized.usage, normalized.surface));
195
+ const oklch = scale[step - 1];
196
+
197
+ return {
198
+ oklch,
199
+ step,
200
+ variantUsed,
201
+ seedUsed,
202
+ };
203
+ }
@@ -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
+ `;