@clhaas/palette-kit 0.1.7 → 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 (293) 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 -20
  111. package/dist/index.js +2 -17
  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 -137
  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/tokens/presetRadixLikeUi.d.ts +0 -5
  291. package/dist/tokens/presetRadixLikeUi.js +0 -55
  292. package/dist/types.d.ts +0 -69
  293. /package/dist/{types.js → cli/args.test.d.ts} +0 -0
@@ -0,0 +1,24 @@
1
+ import type { SrgbColor } from "./types.js";
2
+
3
+ const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
4
+
5
+ const toLinear = (channel: number) => {
6
+ const value = clamp01(channel);
7
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
8
+ };
9
+
10
+ export const relativeLuminance = (color: SrgbColor): number => {
11
+ const r = toLinear(color.r);
12
+ const g = toLinear(color.g);
13
+ const b = toLinear(color.b);
14
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
15
+ };
16
+
17
+ export const contrastRatio = (fg: SrgbColor, bg: SrgbColor): number => {
18
+ const fgLum = relativeLuminance(fg);
19
+ const bgLum = relativeLuminance(bg);
20
+ const lighter = Math.max(fgLum, bgLum);
21
+ const darker = Math.min(fgLum, bgLum);
22
+ const ratio = (lighter + 0.05) / (darker + 0.05);
23
+ return Number.isFinite(ratio) ? ratio : Number.NaN;
24
+ };
@@ -0,0 +1,78 @@
1
+ import { onSolid } from "../engine/onSolid.js";
2
+ import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
3
+ import { serializeResolved } from "../serialize/serializeColor.js";
4
+ import type {
5
+ ColorContext,
6
+ ColorQuery,
7
+ ColorRole,
8
+ OnSolidQuery,
9
+ OutputOptions,
10
+ ResolvedColor,
11
+ } from "../types/index.js";
12
+ import { resolve } from "./resolve.js";
13
+ import { resolveMany } from "./resolveMany.js";
14
+
15
+ export type PaletteTheme = {
16
+ /**
17
+ * Resolve a single color query to the core OKLCH output shape.
18
+ */
19
+ resolve: (query: ColorQuery) => BaseResolvedColor;
20
+ /**
21
+ * Resolve a batch of color queries while preserving input order.
22
+ */
23
+ resolveMany: (queries: ColorQuery[]) => BaseResolvedColor[];
24
+ /**
25
+ * Resolve a color role with inference and DX validation.
26
+ *
27
+ * Inference and strict/non-strict behavior is shared with `theme.resolve(...)` and is
28
+ * implemented inside query normalization.
29
+ *
30
+ * When `output.strict` is true, missing inference throws an error; otherwise
31
+ * safe defaults are used.
32
+ */
33
+ color: (role: ColorRole, options?: Omit<ColorQuery, "role">) => BaseResolvedColor;
34
+ /**
35
+ * Resolve a foreground color against a solid background (APCA/WCAG aware).
36
+ */
37
+ onSolid: (query: OnSolidQuery) => BaseResolvedColor;
38
+ /**
39
+ * Serialize a resolved color query for external outputs (CSS, RN, JSON, etc.).
40
+ */
41
+ serialize: (query: ColorQuery, options?: OutputOptions) => ResolvedColor;
42
+ /**
43
+ * Return a new theme instance with a bound context.
44
+ */
45
+ withContext: (context: ColorContext) => PaletteTheme;
46
+ };
47
+
48
+ export function createTheme(config: ThemeConfig): PaletteTheme {
49
+ const themeConfig: ThemeConfig = {
50
+ ...config,
51
+ preset: config.preset ?? "modern",
52
+ variants: config.variants ?? {},
53
+ };
54
+
55
+ const applyBoundContext = <T extends { context?: ColorContext }>(
56
+ query: T,
57
+ boundContext?: ColorContext,
58
+ ) => (boundContext ? { context: boundContext, ...query } : query);
59
+
60
+ const buildTheme = (boundContext?: ColorContext): PaletteTheme => ({
61
+ resolve: (query) => resolve(applyBoundContext(query, boundContext), themeConfig),
62
+ resolveMany: (queries) =>
63
+ resolveMany(
64
+ queries.map((query) => applyBoundContext(query, boundContext)),
65
+ themeConfig,
66
+ ),
67
+ color: (role, options) =>
68
+ resolve(applyBoundContext({ role, ...(options ?? {}) }, boundContext), themeConfig),
69
+ onSolid: (query) => onSolid(applyBoundContext(query, boundContext), themeConfig),
70
+ serialize: (query, options) => {
71
+ const resolved = resolve(applyBoundContext(query, boundContext), themeConfig);
72
+ return serializeResolved(resolved, options);
73
+ },
74
+ withContext: (context) => buildTheme(context),
75
+ });
76
+
77
+ return buildTheme();
78
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createTheme } from "./createTheme.js";
4
+ import type { ColorQuery } from "../types/index.js";
5
+
6
+ describe("Phase 2 helpers", () => {
7
+ const theme = createTheme({
8
+ seeds: {
9
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
10
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
11
+ },
12
+ preset: "modern",
13
+ });
14
+
15
+ it("resolveMany preserves input order", () => {
16
+ const queries: ColorQuery[] = [
17
+ { role: "bg.app", usage: "bg", surface: "app", context: "light" },
18
+ { role: "text.primary", usage: "text", surface: "surface", context: "light" },
19
+ ];
20
+
21
+ const expected = queries.map((query) => theme.resolve(query));
22
+ const results = theme.resolveMany(queries);
23
+
24
+ expect(results.map((result) => result.step)).toEqual(expected.map((result) => result.step));
25
+ expect(results.map((result) => result.seedUsed)).toEqual(
26
+ expected.map((result) => result.seedUsed),
27
+ );
28
+ });
29
+
30
+ it("withContext applies bound context and allows overrides", () => {
31
+ const darkTheme = theme.withContext("dark");
32
+ const bound = darkTheme.resolve({ role: "bg.app", usage: "bg", surface: "app" });
33
+ const explicit: ColorQuery = {
34
+ role: "bg.app",
35
+ usage: "bg",
36
+ surface: "app",
37
+ context: "dark",
38
+ };
39
+ const explicitResolved = theme.resolve(explicit);
40
+
41
+ expect(bound.step).toBe(explicitResolved.step);
42
+
43
+ const override = darkTheme.resolve({
44
+ role: "bg.app",
45
+ usage: "bg",
46
+ surface: "app",
47
+ context: "light",
48
+ });
49
+ const light: ColorQuery = {
50
+ role: "bg.app",
51
+ usage: "bg",
52
+ surface: "app",
53
+ context: "light",
54
+ };
55
+ const lightResolved = theme.resolve(light);
56
+
57
+ expect(override.step).toBe(lightResolved.step);
58
+ });
59
+
60
+ it("theme.color infers usage and surface from role", () => {
61
+ const inferred = theme.color("bg.app");
62
+ const explicit = theme.resolve({ role: "bg.app", usage: "bg", surface: "app" });
63
+
64
+ expect(inferred.step).toBe(explicit.step);
65
+ expect(inferred.seedUsed).toBe(explicit.seedUsed);
66
+ });
67
+
68
+ it("theme.color throws in strict mode when inference is missing", () => {
69
+ expect(() =>
70
+ theme.color("custom.role", {
71
+ output: { strict: true },
72
+ }),
73
+ ).toThrow(/Usage is required for role/i);
74
+
75
+ expect(() =>
76
+ theme.color("text.custom", {
77
+ output: { strict: true },
78
+ }),
79
+ ).toThrow(/Surface is required for role/i);
80
+ });
81
+
82
+ });
@@ -0,0 +1,7 @@
1
+ export { createTheme } from "./createTheme.js";
2
+ export {
3
+ resolveToken,
4
+ resolveTokenRegistry,
5
+ validateTokenDefinition,
6
+ validateTokenRegistry,
7
+ } from "./tokenRegistry.js";
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import * as apca from "../contrast/apca.js";
4
+ import { blendSrgb, toSrgbColor } from "../contrast/utils.js";
5
+ import type { OkLchColor } from "../engine/generateScale.js";
6
+ import * as operators from "../engine/applyOperators.js";
7
+ import { createTheme } from "./createTheme.js";
8
+
9
+ const passesApca = (fg: OkLchColor, bg: OkLchColor, targetLc: number, alpha = 1) => {
10
+ const fgSrgb = toSrgbColor(fg);
11
+ const bgSrgb = toSrgbColor(bg);
12
+
13
+ if (!fgSrgb || !bgSrgb) {
14
+ return false;
15
+ }
16
+
17
+ const composite = blendSrgb(fgSrgb, bgSrgb, alpha);
18
+ const value = Math.abs(apca.computeApcaLc(composite, bgSrgb));
19
+ return value >= targetLc;
20
+ };
21
+
22
+ const theme = createTheme({
23
+ seeds: {
24
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
25
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
26
+ },
27
+ preset: "modern",
28
+ });
29
+
30
+ describe("onSolid", () => {
31
+ it("chooses a white-ish foreground on dark solids", () => {
32
+ const bg = theme.resolve({
33
+ role: "action.primary",
34
+ usage: "bg",
35
+ surface: "solid",
36
+ context: "dark",
37
+ });
38
+ const result = theme.onSolid({
39
+ bgRole: "action.primary",
40
+ usage: "text",
41
+ context: "dark",
42
+ });
43
+
44
+ expect(bg.oklch.l).toBeLessThan(50);
45
+ expect(result.oklch.l).toBeGreaterThan(50);
46
+ expect(passesApca(result.oklch, bg.oklch, 75, result.oklch.alpha ?? 1)).toBe(true);
47
+ });
48
+
49
+ it("chooses a black-ish foreground on light solids", () => {
50
+ const bg = theme.resolve({
51
+ role: "action.primary",
52
+ usage: "bg",
53
+ surface: "solid",
54
+ context: "light",
55
+ });
56
+ const result = theme.onSolid({
57
+ bgRole: "action.primary",
58
+ usage: "icon",
59
+ context: "light",
60
+ });
61
+
62
+ expect(bg.oklch.l).toBeGreaterThanOrEqual(50);
63
+ expect(result.oklch.l).toBeLessThan(50);
64
+ expect(passesApca(result.oklch, bg.oklch, 60, result.oklch.alpha ?? 1)).toBe(true);
65
+ });
66
+
67
+ it("raises alpha to 1 when needed for contrast", () => {
68
+ const bg = theme.resolve({
69
+ role: "action.primary",
70
+ usage: "bg",
71
+ surface: "solid",
72
+ context: "dark",
73
+ });
74
+ const result = theme.onSolid({
75
+ bgRole: "action.primary",
76
+ usage: "icon",
77
+ context: "dark",
78
+ contrast: { model: "apca", targetLc: 90 },
79
+ });
80
+
81
+ expect(result.oklch.alpha).toBe(1);
82
+ expect(passesApca(result.oklch, bg.oklch, 90, 1)).toBe(true);
83
+ expect(passesApca(result.oklch, bg.oklch, 90, 0.72)).toBe(false);
84
+ });
85
+
86
+ it("throws in strict mode when contrast is impossible", () => {
87
+ expect(() =>
88
+ theme.onSolid({
89
+ bgRole: "action.primary",
90
+ usage: "text",
91
+ context: "light",
92
+ contrast: { model: "apca", targetLc: 140 },
93
+ output: { strict: true },
94
+ }),
95
+ ).toThrow(/onSolid contrast failed|Contrast solver failed/i);
96
+ });
97
+
98
+ it("accepts results within epsilon for strict checks", () => {
99
+ const spy = vi.spyOn(apca, "computeApcaLc").mockImplementation(() => 74.995);
100
+
101
+ try {
102
+ expect(() =>
103
+ theme.onSolid({
104
+ bgRole: "action.primary",
105
+ usage: "text",
106
+ context: "light",
107
+ contrast: { model: "apca", targetLc: 75 },
108
+ output: { strict: true },
109
+ }),
110
+ ).not.toThrow();
111
+ } finally {
112
+ spy.mockRestore();
113
+ }
114
+ });
115
+
116
+ it("keeps alpha at 1 when mode is none", () => {
117
+ const result = theme.onSolid({
118
+ bgRole: "action.primary",
119
+ usage: "text",
120
+ context: "dark",
121
+ alpha: { mode: "none" },
122
+ });
123
+
124
+ expect(result.oklch.alpha).toBe(1);
125
+ });
126
+
127
+ it("applies state and emphasis to the background before solving", () => {
128
+ const spy = vi.spyOn(operators, "applyOperators");
129
+
130
+ theme.onSolid({
131
+ bgRole: "action.primary",
132
+ usage: "text",
133
+ context: "light",
134
+ state: "hover",
135
+ emphasis: "strong",
136
+ });
137
+
138
+ expect(spy).toHaveBeenCalledWith(
139
+ expect.any(Object),
140
+ expect.objectContaining({ state: "hover", emphasis: "strong" }),
141
+ expect.any(Object),
142
+ );
143
+
144
+ spy.mockRestore();
145
+ });
146
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { serializeColor } from "../export/serializeColor.js";
4
+ import { createTheme } from "./createTheme.js";
5
+
6
+ const buildTheme = () =>
7
+ createTheme({
8
+ seeds: {
9
+ light: { neutral: "#111827", accent: "#3d63dd" },
10
+ dark: { neutral: "#111827", accent: "#3d63dd" },
11
+ },
12
+ });
13
+
14
+ const toOklchString = (oklch: { l: number; c: number; h: number; alpha?: number }) =>
15
+ serializeColor(oklch, { preferSpace: "oklch", includeMeta: true });
16
+
17
+ describe("QA v1", () => {
18
+ it("resolves light/dark background without NaNs", () => {
19
+ const theme = buildTheme();
20
+
21
+ const light = theme.resolve({
22
+ role: "bg.app",
23
+ usage: "bg",
24
+ context: "light",
25
+ surface: "app",
26
+ });
27
+ const dark = theme.resolve({
28
+ role: "bg.app",
29
+ usage: "bg",
30
+ context: "dark",
31
+ surface: "app",
32
+ });
33
+
34
+ const lightSerialized = toOklchString(light.oklch);
35
+ const darkSerialized = toOklchString(dark.oklch);
36
+
37
+ expect(typeof lightSerialized.value).toBe("string");
38
+ expect(lightSerialized.value.includes("oklch(")).toBe(true);
39
+ expect(Number.isFinite(lightSerialized.alpha)).toBe(true);
40
+ expect(lightSerialized.alpha).toBeGreaterThanOrEqual(0);
41
+ expect(lightSerialized.alpha).toBeLessThanOrEqual(1);
42
+
43
+ expect(typeof darkSerialized.value).toBe("string");
44
+ expect(darkSerialized.value.includes("oklch(")).toBe(true);
45
+ expect(Number.isFinite(darkSerialized.alpha)).toBe(true);
46
+ expect(darkSerialized.alpha).toBeGreaterThanOrEqual(0);
47
+ expect(darkSerialized.alpha).toBeLessThanOrEqual(1);
48
+ });
49
+
50
+ it("solves primary/secondary text on solid backgrounds", () => {
51
+ const theme = buildTheme();
52
+
53
+ const primary = theme.onSolid({
54
+ bgRole: "action.primary",
55
+ usage: "text",
56
+ context: "light",
57
+ contrast: { model: "apca", targetLc: 75 },
58
+ });
59
+ const secondary = theme.onSolid({
60
+ bgRole: "action.primary",
61
+ usage: "text",
62
+ context: "light",
63
+ contrast: { model: "apca", targetLc: 60 },
64
+ });
65
+
66
+ const primarySerialized = toOklchString(primary.oklch);
67
+ const secondarySerialized = toOklchString(secondary.oklch);
68
+
69
+ expect(primarySerialized.value).not.toBe("");
70
+ expect(primarySerialized.alpha).toBeGreaterThanOrEqual(0);
71
+ expect(primarySerialized.alpha).toBeLessThanOrEqual(1);
72
+
73
+ expect(secondarySerialized.value).not.toBe("");
74
+ expect(secondarySerialized.alpha).toBeGreaterThanOrEqual(0);
75
+ expect(secondarySerialized.alpha).toBeLessThanOrEqual(1);
76
+ });
77
+
78
+ it("handles solid button states and onSolid text", () => {
79
+ const theme = buildTheme();
80
+ const base = {
81
+ role: "action.primary",
82
+ usage: "bg" as const,
83
+ surface: "solid" as const,
84
+ context: "light" as const,
85
+ };
86
+
87
+ const states = ["default", "hover", "active"] as const;
88
+ const serializedStates = states.map((state) =>
89
+ toOklchString(theme.resolve({ ...base, state }).oklch).value,
90
+ );
91
+
92
+ serializedStates.forEach((value) => {
93
+ expect(value.startsWith("oklch(")).toBe(true);
94
+ });
95
+
96
+ const onSolidText = toOklchString(
97
+ theme.onSolid({ bgRole: "action.primary", usage: "text", context: "light" }).oklch,
98
+ );
99
+
100
+ expect(onSolidText.alpha).toBeGreaterThanOrEqual(0.85);
101
+ expect(onSolidText.alpha).toBeLessThanOrEqual(1);
102
+ });
103
+
104
+ it("produces a distinct focus ring color with meta", () => {
105
+ const theme = buildTheme();
106
+
107
+ const ring = toOklchString(
108
+ theme.resolve({
109
+ role: "focus.ring",
110
+ variant: "accent",
111
+ usage: "ring",
112
+ context: "light",
113
+ surface: "surface",
114
+ contrast: { model: "apca", targetLc: 45 },
115
+ }).oklch,
116
+ );
117
+
118
+ const surface = toOklchString(
119
+ theme.resolve({
120
+ role: "surface.card",
121
+ usage: "bg",
122
+ context: "light",
123
+ surface: "surface",
124
+ }).oklch,
125
+ );
126
+
127
+ expect(ring.value).not.toBe(surface.value);
128
+ expect(ring.meta?.gamutMapping).toBeDefined();
129
+ });
130
+
131
+ it("keeps overlay scrim alpha stable", () => {
132
+ const theme = buildTheme();
133
+
134
+ const scrim = toOklchString(
135
+ theme.resolve({
136
+ role: "overlay.scrim",
137
+ usage: "bg",
138
+ surface: "overlay",
139
+ context: "dark",
140
+ alpha: { mode: "fixed", alpha: 0.55 },
141
+ }).oklch,
142
+ );
143
+
144
+ expect(scrim.alpha).toBeGreaterThanOrEqual(0);
145
+ expect(scrim.alpha).toBeLessThanOrEqual(1);
146
+ expect(scrim.value.includes("oklch(")).toBe(true);
147
+ expect(scrim.value.includes("NaN")).toBe(false);
148
+ });
149
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createTheme } from "./createTheme.js";
4
+
5
+ const theme = createTheme({
6
+ seeds: {
7
+ light: { neutral: "#8B8D98", accent: "#3D63DD" },
8
+ dark: { neutral: "#8B8D98", accent: "#3D63DD" },
9
+ },
10
+ preset: "modern",
11
+ });
12
+
13
+ describe("resolve", () => {
14
+ it("applies state operators via theme.resolve", () => {
15
+ const baseLight = theme.resolve({
16
+ role: "bg.solid",
17
+ usage: "bg",
18
+ surface: "solid",
19
+ context: "light",
20
+ });
21
+ const hoverLight = theme.resolve({
22
+ role: "bg.solid",
23
+ usage: "bg",
24
+ surface: "solid",
25
+ context: "light",
26
+ state: "hover",
27
+ });
28
+ const baseDark = theme.resolve({
29
+ role: "bg.solid",
30
+ usage: "bg",
31
+ surface: "solid",
32
+ context: "dark",
33
+ });
34
+ const hoverDark = theme.resolve({
35
+ role: "bg.solid",
36
+ usage: "bg",
37
+ surface: "solid",
38
+ context: "dark",
39
+ state: "hover",
40
+ });
41
+ const activeDark = theme.resolve({
42
+ role: "bg.solid",
43
+ usage: "bg",
44
+ surface: "solid",
45
+ context: "dark",
46
+ state: "active",
47
+ });
48
+ const activeLight = theme.resolve({
49
+ role: "bg.solid",
50
+ usage: "bg",
51
+ surface: "solid",
52
+ context: "light",
53
+ state: "active",
54
+ });
55
+ const disabledLight = theme.resolve({
56
+ role: "bg.solid",
57
+ usage: "bg",
58
+ surface: "solid",
59
+ context: "light",
60
+ state: "disabled",
61
+ });
62
+ const disabledDark = theme.resolve({
63
+ role: "bg.solid",
64
+ usage: "bg",
65
+ surface: "solid",
66
+ context: "dark",
67
+ state: "disabled",
68
+ });
69
+
70
+ expect(hoverLight.oklch.l).toBeLessThan(baseLight.oklch.l);
71
+ expect(hoverDark.oklch.l).toBeGreaterThan(baseDark.oklch.l);
72
+ expect(Math.abs(activeLight.oklch.l - baseLight.oklch.l)).toBeGreaterThan(
73
+ Math.abs(hoverLight.oklch.l - baseLight.oklch.l),
74
+ );
75
+ expect(Math.abs(activeDark.oklch.l - baseDark.oklch.l)).toBeGreaterThan(
76
+ Math.abs(hoverDark.oklch.l - baseDark.oklch.l),
77
+ );
78
+ expect(disabledLight.oklch.c).toBeLessThan(baseLight.oklch.c * 0.6);
79
+ expect(disabledDark.oklch.c).toBeLessThan(baseDark.oklch.c * 0.6);
80
+ });
81
+
82
+ it("applies emphasis via theme.resolve", () => {
83
+ const base = theme.resolve({
84
+ role: "text.primary",
85
+ usage: "text",
86
+ surface: "surface",
87
+ context: "light",
88
+ });
89
+ const muted = theme.resolve({
90
+ role: "text.primary",
91
+ usage: "text",
92
+ surface: "surface",
93
+ context: "light",
94
+ emphasis: "muted",
95
+ });
96
+
97
+ expect(muted.oklch.c).toBeLessThan(base.oklch.c);
98
+ });
99
+ });
@@ -0,0 +1,11 @@
1
+ import { applyOperators } from "../engine/applyOperators.js";
2
+ import { normalizeQuery } from "../engine/normalize.js";
3
+ import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
4
+ import { resolveBaseColor } from "../engine/resolveBaseColor.js";
5
+ import type { ColorQuery } from "../types/index.js";
6
+
7
+ export function resolve(query: ColorQuery, theme: ThemeConfig): BaseResolvedColor {
8
+ const normalized = normalizeQuery(query);
9
+ const base = resolveBaseColor(normalized, theme);
10
+ return applyOperators(base, normalized, theme);
11
+ }
@@ -0,0 +1,22 @@
1
+ import { applyOperators } from "../engine/applyOperators.js";
2
+ import { normalizeQuery } from "../engine/normalize.js";
3
+ import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
4
+ import { resolveBaseColor } from "../engine/resolveBaseColor.js";
5
+ import type { ColorQuery } from "../types/index.js";
6
+
7
+ /**
8
+ * Resolve a batch of color queries while preserving input order.
9
+ *
10
+ * Convenience helper to resolve multiple color queries in a single call.
11
+ */
12
+ export function resolveMany(queries: ColorQuery[], theme: ThemeConfig): BaseResolvedColor[] {
13
+ const results: BaseResolvedColor[] = [];
14
+
15
+ for (const query of queries) {
16
+ const normalized = normalizeQuery(query);
17
+ const base = resolveBaseColor(normalized, theme);
18
+ results.push(applyOperators(base, normalized, theme));
19
+ }
20
+
21
+ return results;
22
+ }