@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.
- package/.codex/skills/color-pipeline-implementer/SKILL.md +23 -0
- package/.codex/skills/commit-message-crafter/SKILL.md +63 -0
- package/.codex/skills/commit-message-crafter/references/benchmarks.md +20 -0
- package/.codex/skills/contrast-solver-helper/SKILL.md +20 -0
- package/.codex/skills/exporters-builder/SKILL.md +20 -0
- package/.codex/skills/markdownlint-writer/SKILL.md +32 -0
- package/.codex/skills/phase-implementation-runbook/SKILL.md +92 -0
- package/.codex/skills/type-contract-auditor/SKILL.md +21 -0
- package/.github/skills/review-guide/SKILL.md +23 -0
- package/.github/skills/review-guide/references/review-guide-v0.3.md +629 -0
- package/.markdownlint.json +4 -0
- package/AGENTS.md +16 -0
- package/CHANGELOG.md +34 -0
- package/README.md +79 -169
- package/biome.json +43 -0
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +56 -0
- package/dist/cli/args.test.js +22 -0
- package/dist/cli/codegen/__snapshots__/tokens.test.js.snap +87 -0
- package/dist/cli/codegen/tokens.d.ts +12 -0
- package/dist/cli/codegen/tokens.js +139 -0
- package/dist/cli/codegen/tokens.test.d.ts +1 -0
- package/dist/cli/codegen/tokens.test.js +51 -0
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +34 -0
- package/dist/cli/validate.d.ts +2 -0
- package/dist/cli/validate.js +33 -0
- package/dist/cli/validate.test.d.ts +1 -0
- package/dist/cli/validate.test.js +40 -0
- package/dist/cli.js +138 -140
- package/dist/contrast/apca.d.ts +2 -2
- package/dist/contrast/apca.js +14 -4
- package/dist/contrast/apca.test.d.ts +1 -0
- package/dist/contrast/apca.test.js +16 -0
- package/dist/contrast/index.d.ts +4 -0
- package/dist/contrast/index.js +4 -0
- package/dist/contrast/scoring.d.ts +4 -0
- package/dist/contrast/scoring.js +31 -0
- package/dist/contrast/scoring.test.d.ts +1 -0
- package/dist/contrast/scoring.test.js +148 -0
- package/dist/contrast/solver.d.ts +13 -0
- package/dist/contrast/solver.js +170 -0
- package/dist/contrast/solver.test.d.ts +1 -0
- package/dist/contrast/solver.test.js +75 -0
- package/dist/contrast/types.d.ts +17 -0
- package/dist/contrast/types.js +1 -0
- package/dist/contrast/utils.d.ts +4 -0
- package/dist/contrast/utils.js +18 -0
- package/dist/contrast/wcag2.d.ts +3 -0
- package/dist/contrast/wcag2.js +19 -0
- package/dist/contrast/wcag2.test.d.ts +1 -0
- package/dist/contrast/wcag2.test.js +17 -0
- package/dist/core/createTheme.d.ts +35 -0
- package/dist/core/createTheme.js +24 -0
- package/dist/core/dx-helpers.test.d.ts +1 -0
- package/dist/core/dx-helpers.test.js +61 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/onSolid.test.d.ts +1 -0
- package/dist/core/onSolid.test.js +118 -0
- package/dist/core/qa.v1.test.d.ts +1 -0
- package/dist/core/qa.v1.test.js +112 -0
- package/dist/core/resolve.d.ts +3 -0
- package/dist/core/resolve.js +8 -0
- package/dist/core/resolve.test.d.ts +1 -0
- package/dist/core/resolve.test.js +89 -0
- package/dist/core/resolveMany.d.ts +8 -0
- package/dist/core/resolveMany.js +17 -0
- package/dist/core/tokenRegistry.d.ts +23 -0
- package/dist/core/tokenRegistry.js +83 -0
- package/dist/core/tokenRegistry.test.d.ts +1 -0
- package/dist/core/tokenRegistry.test.js +133 -0
- package/dist/engine/applyOperators.d.ts +3 -0
- package/dist/engine/applyOperators.js +23 -0
- package/dist/engine/context.d.ts +4 -0
- package/dist/engine/context.js +1 -0
- package/dist/engine/gamut.d.ts +13 -0
- package/dist/engine/gamut.js +101 -0
- package/dist/engine/gamut.test.d.ts +1 -0
- package/dist/engine/gamut.test.js +23 -0
- package/dist/engine/generateScale.d.ts +15 -0
- package/dist/engine/generateScale.js +29 -0
- package/dist/engine/generateScale.test.d.ts +1 -0
- package/dist/engine/generateScale.test.js +32 -0
- package/dist/engine/index.d.ts +8 -0
- package/dist/engine/index.js +4 -0
- package/dist/engine/normalize.d.ts +43 -0
- package/dist/engine/normalize.js +403 -0
- package/dist/engine/normalize.test.d.ts +1 -0
- package/dist/engine/normalize.test.js +136 -0
- package/dist/engine/onSolid.d.ts +3 -0
- package/dist/engine/onSolid.js +110 -0
- package/dist/engine/resolveBaseColor.d.ts +25 -0
- package/dist/engine/resolveBaseColor.js +127 -0
- package/dist/engine/resolveBaseColor.test.d.ts +1 -0
- package/dist/engine/resolveBaseColor.test.js +97 -0
- package/dist/export/__snapshots__/exportTheme.test.js.snap +74 -0
- package/dist/export/exportTheme.d.ts +47 -0
- package/dist/export/exportTheme.js +170 -0
- package/dist/export/exportTheme.test.d.ts +1 -0
- package/dist/export/exportTheme.test.js +118 -0
- package/dist/export/index.d.ts +1 -0
- package/dist/export/index.js +1 -0
- package/dist/export/serializeColor.d.ts +1 -0
- package/dist/export/serializeColor.js +1 -0
- package/dist/export/serializeColor.test.d.ts +1 -0
- package/dist/export/serializeColor.test.js +54 -0
- package/dist/export.d.ts +1 -0
- package/dist/export.js +1 -0
- package/dist/index.d.ts +3 -22
- package/dist/index.js +2 -18
- package/dist/operators/emphasis.d.ts +3 -0
- package/dist/operators/emphasis.js +113 -0
- package/dist/operators/emphasis.test.d.ts +1 -0
- package/dist/operators/emphasis.test.js +69 -0
- package/dist/operators/index.d.ts +3 -0
- package/dist/operators/index.js +2 -0
- package/dist/operators/state.d.ts +3 -0
- package/dist/operators/state.js +102 -0
- package/dist/operators/state.test.d.ts +1 -0
- package/dist/operators/state.test.js +48 -0
- package/dist/operators/types.d.ts +13 -0
- package/dist/operators/types.js +1 -0
- package/dist/operators/utils.d.ts +16 -0
- package/dist/operators/utils.js +23 -0
- package/dist/presets/curves.d.ts +28 -0
- package/dist/presets/curves.js +145 -0
- package/dist/presets/index.d.ts +2 -0
- package/dist/presets/index.js +1 -0
- package/dist/presets/tokens/index.d.ts +3 -0
- package/dist/presets/tokens/index.js +3 -0
- package/dist/presets/tokens/minimal-ui.d.ts +6 -0
- package/dist/presets/tokens/minimal-ui.js +53 -0
- package/dist/presets/tokens/modern-ui.d.ts +5 -0
- package/dist/presets/tokens/modern-ui.js +83 -0
- package/dist/presets/tokens/presets.test.d.ts +1 -0
- package/dist/presets/tokens/presets.test.js +31 -0
- package/dist/presets/tokens/radixLike-ui.d.ts +6 -0
- package/dist/presets/tokens/radixLike-ui.js +77 -0
- package/dist/serialize/index.d.ts +1 -0
- package/dist/serialize/index.js +1 -0
- package/dist/serialize/normalizeOutput.d.ts +6 -0
- package/dist/serialize/normalizeOutput.js +45 -0
- package/dist/serialize/serializeColor.d.ts +21 -0
- package/dist/serialize/serializeColor.js +178 -0
- package/dist/serialize/serializeResolved.test.d.ts +1 -0
- package/dist/serialize/serializeResolved.test.js +45 -0
- package/dist/serialize.d.ts +1 -0
- package/dist/serialize.js +1 -0
- package/dist/types/index.d.ts +187 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/clamp.d.ts +1 -0
- package/dist/utils/clamp.js +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/lerp.d.ts +1 -0
- package/dist/utils/lerp.js +1 -0
- package/dist/utils/parseColor.d.ts +6 -0
- package/dist/utils/parseColor.js +67 -0
- package/dist/utils/parseColor.test.d.ts +1 -0
- package/dist/utils/parseColor.test.js +51 -0
- package/dist/utils/smoothstep.d.ts +1 -0
- package/dist/utils/smoothstep.js +5 -0
- package/package.json +19 -12
- package/planning/phase-10-review.md +550 -0
- package/planning/phase-7-review.md +411 -0
- package/planning/phase-8-review.md +669 -0
- package/planning/phase-9-review.md +564 -0
- package/planning/roadmap-v0.3.md +284 -0
- package/planning/spec-serializer-v0.3.md +324 -0
- package/planning/spec-v0.3.md +305 -0
- package/src/cli/args.test.ts +28 -0
- package/src/cli/args.ts +66 -0
- package/src/cli/codegen/__snapshots__/tokens.test.ts.snap +87 -0
- package/src/cli/codegen/tokens.test.ts +61 -0
- package/src/cli/codegen/tokens.ts +191 -0
- package/src/cli/config.ts +71 -0
- package/src/cli/validate.test.ts +49 -0
- package/src/cli/validate.ts +38 -0
- package/src/cli.ts +183 -0
- package/src/contrast/apca.test.ts +20 -0
- package/src/contrast/apca.ts +26 -0
- package/src/contrast/index.ts +4 -0
- package/src/contrast/scoring.test.ts +188 -0
- package/src/contrast/scoring.ts +48 -0
- package/src/contrast/solver.test.ts +147 -0
- package/src/contrast/solver.ts +235 -0
- package/src/contrast/types.ts +20 -0
- package/src/contrast/utils.ts +28 -0
- package/src/contrast/wcag2.test.ts +21 -0
- package/src/contrast/wcag2.ts +24 -0
- package/src/core/createTheme.ts +78 -0
- package/src/core/dx-helpers.test.ts +82 -0
- package/src/core/index.ts +7 -0
- package/src/core/onSolid.test.ts +146 -0
- package/src/core/qa.v1.test.ts +149 -0
- package/src/core/resolve.test.ts +99 -0
- package/src/core/resolve.ts +11 -0
- package/src/core/resolveMany.ts +22 -0
- package/src/core/tokenRegistry.test.ts +153 -0
- package/src/core/tokenRegistry.ts +114 -0
- package/src/engine/applyOperators.ts +32 -0
- package/src/engine/context.ts +8 -0
- package/src/engine/gamut.test.ts +30 -0
- package/src/engine/gamut.ts +144 -0
- package/src/engine/generateScale.test.ts +46 -0
- package/src/engine/generateScale.ts +48 -0
- package/src/engine/index.ts +8 -0
- package/src/engine/normalize.test.ts +222 -0
- package/src/engine/normalize.ts +550 -0
- package/src/engine/onSolid.ts +178 -0
- package/src/engine/resolveBaseColor.test.ts +117 -0
- package/src/engine/resolveBaseColor.ts +203 -0
- package/src/export/__snapshots__/exportTheme.test.ts.snap +74 -0
- package/src/export/exportTheme.test.ts +144 -0
- package/src/export/exportTheme.ts +251 -0
- package/src/export/index.ts +1 -0
- package/src/export/serializeColor.test.ts +73 -0
- package/src/export/serializeColor.ts +1 -0
- package/src/export.ts +1 -0
- package/src/index.ts +3 -0
- package/src/operators/emphasis.test.ts +85 -0
- package/src/operators/emphasis.ts +132 -0
- package/src/operators/index.ts +3 -0
- package/src/operators/state.test.ts +66 -0
- package/src/operators/state.ts +122 -0
- package/src/operators/types.ts +14 -0
- package/src/operators/utils.ts +44 -0
- package/src/presets/curves.ts +168 -0
- package/src/presets/index.ts +2 -0
- package/src/presets/tokens/index.ts +3 -0
- package/src/presets/tokens/minimal-ui.ts +55 -0
- package/src/presets/tokens/modern-ui.ts +85 -0
- package/src/presets/tokens/presets.test.ts +46 -0
- package/src/presets/tokens/radixLike-ui.ts +79 -0
- package/src/serialize/index.ts +1 -0
- package/src/serialize/normalizeOutput.ts +63 -0
- package/src/serialize/serializeColor.ts +260 -0
- package/src/serialize/serializeResolved.test.ts +57 -0
- package/src/serialize.ts +1 -0
- package/src/types/index.ts +207 -0
- package/src/utils/clamp.ts +2 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/lerp.ts +1 -0
- package/src/utils/parseColor.test.ts +66 -0
- package/src/utils/parseColor.ts +87 -0
- package/src/utils/smoothstep.ts +6 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +15 -0
- package/dist/alpha/generateAlphaScale.d.ts +0 -5
- package/dist/alpha/generateAlphaScale.js +0 -34
- package/dist/contrast/onSolid.d.ts +0 -6
- package/dist/contrast/onSolid.js +0 -28
- package/dist/contrast/solveText.d.ts +0 -2
- package/dist/contrast/solveText.js +0 -31
- package/dist/createTheme.d.ts +0 -38
- package/dist/createTheme.js +0 -148
- package/dist/data/radixSeeds.d.ts +0 -3
- package/dist/data/radixSeeds.js +0 -34
- package/dist/diagnostics/analyzeScale.d.ts +0 -2
- package/dist/diagnostics/analyzeScale.js +0 -7
- package/dist/diagnostics/analyzeTheme.d.ts +0 -2
- package/dist/diagnostics/analyzeTheme.js +0 -35
- package/dist/diagnostics/warnings.d.ts +0 -2
- package/dist/diagnostics/warnings.js +0 -20
- package/dist/engine/curves.d.ts +0 -9
- package/dist/engine/curves.js +0 -48
- package/dist/engine/oklch.d.ts +0 -8
- package/dist/engine/oklch.js +0 -40
- package/dist/engine/templates.d.ts +0 -14
- package/dist/engine/templates.js +0 -45
- package/dist/exporters/selectColorMode.d.ts +0 -2
- package/dist/exporters/selectColorMode.js +0 -19
- package/dist/exporters/toCssVars.d.ts +0 -13
- package/dist/exporters/toCssVars.js +0 -108
- package/dist/exporters/toJson.d.ts +0 -3
- package/dist/exporters/toJson.js +0 -25
- package/dist/exporters/toReactNative.d.ts +0 -54
- package/dist/exporters/toReactNative.js +0 -33
- package/dist/exporters/toTailwind.d.ts +0 -17
- package/dist/exporters/toTailwind.js +0 -111
- package/dist/exporters/toTs.d.ts +0 -3
- package/dist/exporters/toTs.js +0 -43
- package/dist/generateScale.d.ts +0 -48
- package/dist/generateScale.js +0 -274
- package/dist/overlays/generateOverlayScale.d.ts +0 -2
- package/dist/overlays/generateOverlayScale.js +0 -34
- package/dist/text/generateTextScale.d.ts +0 -8
- package/dist/text/generateTextScale.js +0 -18
- package/dist/text/resolveOnBgText.d.ts +0 -9
- package/dist/text/resolveOnBgText.js +0 -28
- package/dist/tokens/presetRadixLikeUi.d.ts +0 -5
- package/dist/tokens/presetRadixLikeUi.js +0 -55
- package/dist/types.d.ts +0 -69
- /package/dist/{types.js → cli/args.test.d.ts} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createTheme } from "./createTheme.js";
|
|
4
|
+
import { resolveToken, resolveTokenRegistry, validateTokenRegistry } from "./tokenRegistry.js";
|
|
5
|
+
import type { TokenRegistry } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
describe("token registry", () => {
|
|
8
|
+
const theme = createTheme({
|
|
9
|
+
seeds: {
|
|
10
|
+
light: { neutral: "#8B8D98", accent: "#3D63DD" },
|
|
11
|
+
dark: { neutral: "#8B8D98", accent: "#3D63DD" },
|
|
12
|
+
},
|
|
13
|
+
preset: "modern",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("validates tokens and resolves through theme.resolve", () => {
|
|
17
|
+
const registry: TokenRegistry = {
|
|
18
|
+
tokens: {
|
|
19
|
+
"bg.app": {
|
|
20
|
+
name: "bg.app",
|
|
21
|
+
description: "App background",
|
|
22
|
+
category: "background",
|
|
23
|
+
query: { role: "bg.app", usage: "bg", surface: "app" },
|
|
24
|
+
states: { hover: true },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
validateTokenRegistry(registry);
|
|
30
|
+
const resolved = resolveTokenRegistry(registry, theme);
|
|
31
|
+
|
|
32
|
+
expect(resolved["bg.app"].step).toBeGreaterThan(0);
|
|
33
|
+
|
|
34
|
+
const single = resolveToken(registry.tokens["bg.app"], theme);
|
|
35
|
+
const expected = theme.resolve(registry.tokens["bg.app"].query);
|
|
36
|
+
expect(single.step).toBe(expected.step);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("throws when required query fields are missing", () => {
|
|
40
|
+
const registry: TokenRegistry = {
|
|
41
|
+
tokens: {
|
|
42
|
+
"text.primary": {
|
|
43
|
+
name: "text.primary",
|
|
44
|
+
query: { role: "text.primary", usage: "text" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
expect(() => validateTokenRegistry(registry)).toThrow(/requires a surface/i);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws when output options are provided", () => {
|
|
53
|
+
const registry: TokenRegistry = {
|
|
54
|
+
tokens: {
|
|
55
|
+
"bg.app": {
|
|
56
|
+
name: "bg.app",
|
|
57
|
+
query: {
|
|
58
|
+
role: "bg.app",
|
|
59
|
+
usage: "bg",
|
|
60
|
+
surface: "app",
|
|
61
|
+
output: { strict: true },
|
|
62
|
+
} as never,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
expect(() => validateTokenRegistry(registry)).toThrow(/must not include output/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("treats whitespace-padded default state as default", () => {
|
|
71
|
+
const registry: TokenRegistry = {
|
|
72
|
+
tokens: {
|
|
73
|
+
"bg.app": {
|
|
74
|
+
name: "bg.app",
|
|
75
|
+
query: {
|
|
76
|
+
role: "bg.app",
|
|
77
|
+
usage: "bg",
|
|
78
|
+
surface: "app",
|
|
79
|
+
state: " default " as never,
|
|
80
|
+
} as never,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
expect(() => validateTokenRegistry(registry)).not.toThrow();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("throws when token query encodes a non-default state", () => {
|
|
89
|
+
const registry: TokenRegistry = {
|
|
90
|
+
tokens: {
|
|
91
|
+
"bg.app": {
|
|
92
|
+
name: "bg.app",
|
|
93
|
+
query: { role: "bg.app", usage: "bg", surface: "app", state: "hover" as never } as never,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
expect(() => validateTokenRegistry(registry)).toThrow(/must not encode state/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("throws when token query includes a literal background color hint", () => {
|
|
102
|
+
const registry: TokenRegistry = {
|
|
103
|
+
tokens: {
|
|
104
|
+
"bg.app": {
|
|
105
|
+
name: "bg.app",
|
|
106
|
+
query: {
|
|
107
|
+
role: "bg.app",
|
|
108
|
+
usage: "bg",
|
|
109
|
+
surface: "app",
|
|
110
|
+
on: { kind: "color", value: "#fff" } as never,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
expect(() => validateTokenRegistry(registry)).toThrow(/literal background color/i);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("throws when token states include invalid keys or non-true values", () => {
|
|
120
|
+
const invalidKey: TokenRegistry = {
|
|
121
|
+
tokens: {
|
|
122
|
+
"bg.app": {
|
|
123
|
+
name: "bg.app",
|
|
124
|
+
query: { role: "bg.app", usage: "bg", surface: "app" },
|
|
125
|
+
states: { nope: true } as never,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
expect(() => validateTokenRegistry(invalidKey)).toThrow(/Invalid token state/i);
|
|
130
|
+
|
|
131
|
+
const includesDefault: TokenRegistry = {
|
|
132
|
+
tokens: {
|
|
133
|
+
"bg.app": {
|
|
134
|
+
name: "bg.app",
|
|
135
|
+
query: { role: "bg.app", usage: "bg", surface: "app" },
|
|
136
|
+
states: { default: true } as never,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
expect(() => validateTokenRegistry(includesDefault)).toThrow(/Invalid token state/i);
|
|
141
|
+
|
|
142
|
+
const nonTrueValue: TokenRegistry = {
|
|
143
|
+
tokens: {
|
|
144
|
+
"bg.app": {
|
|
145
|
+
name: "bg.app",
|
|
146
|
+
query: { role: "bg.app", usage: "bg", surface: "app" },
|
|
147
|
+
states: { hover: false } as never,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
expect(() => validateTokenRegistry(nonTrueValue)).toThrow(/must be true/i);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { COLOR_STATES, normalizeQuery } from "../engine/normalize.js";
|
|
2
|
+
import type { BaseResolvedColor } from "../engine/resolveBaseColor.js";
|
|
3
|
+
import type { TokenDefinition, TokenRegistry, TokenState } from "../types/index.js";
|
|
4
|
+
import type { PaletteTheme } from "./createTheme.js";
|
|
5
|
+
|
|
6
|
+
const ALLOWED_TOKEN_STATES: TokenState[] = COLOR_STATES.filter(
|
|
7
|
+
(state): state is TokenState => state !== "default",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const validateTokenStates = (states: TokenDefinition["states"], name: string) => {
|
|
11
|
+
if (!states) return;
|
|
12
|
+
|
|
13
|
+
for (const [state, enabled] of Object.entries(states)) {
|
|
14
|
+
if (enabled !== true) {
|
|
15
|
+
throw new Error(`Token "${name}" state "${state}" must be true`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!ALLOWED_TOKEN_STATES.includes(state as TokenState)) {
|
|
19
|
+
throw new Error(`Invalid token state "${state}" for "${name}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate a token definition for safe registry usage.
|
|
26
|
+
*
|
|
27
|
+
* Guarantees (Phase 3):
|
|
28
|
+
* - registry stays declarative (no output options / no embedded color literals)
|
|
29
|
+
* - tokens are base tokens (interactive states declared via `token.states`)
|
|
30
|
+
*/
|
|
31
|
+
export const validateTokenDefinition = (token: TokenDefinition): void => {
|
|
32
|
+
if (typeof token.name !== "string" || !token.name.trim()) {
|
|
33
|
+
throw new Error("Token name is required");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof token.query?.role !== "string" || !token.query.role.trim()) {
|
|
37
|
+
throw new Error(`Token "${token.name}" requires a query role`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!token.query.usage) {
|
|
41
|
+
throw new Error(`Token "${token.name}" requires a usage`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!token.query.surface) {
|
|
45
|
+
throw new Error(`Token "${token.name}" requires a surface`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rawState = (token.query as { state?: unknown }).state;
|
|
49
|
+
const normalizedState = typeof rawState === "string" ? rawState.trim() : (rawState as undefined);
|
|
50
|
+
if (normalizedState && normalizedState !== "default") {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Token "${token.name}" must not encode state in query; declare supported states via token.states`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const onKind = (token.query.on as { kind?: string } | undefined)?.kind;
|
|
57
|
+
if (onKind === "color") {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Token "${token.name}" must not include a literal background color hint; use { kind: "role" } or { kind: "auto" }`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (token.query.output) {
|
|
64
|
+
throw new Error(`Token "${token.name}" must not include output options`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
validateTokenStates(token.states, token.name);
|
|
68
|
+
|
|
69
|
+
// Delegate to core validation for strict field validation.
|
|
70
|
+
normalizeQuery({ ...token.query, output: { strict: true } });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate a token registry and each token definition it contains.
|
|
75
|
+
*/
|
|
76
|
+
export const validateTokenRegistry = (registry: TokenRegistry): void => {
|
|
77
|
+
const entries = Object.entries(registry.tokens);
|
|
78
|
+
|
|
79
|
+
if (entries.length === 0) {
|
|
80
|
+
throw new Error("Token registry must include at least one token");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [name, token] of entries) {
|
|
84
|
+
if (token.name !== name) {
|
|
85
|
+
throw new Error(`Token name mismatch: expected "${name}", got "${token.name}"`);
|
|
86
|
+
}
|
|
87
|
+
validateTokenDefinition(token);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a token definition through the provided theme.
|
|
93
|
+
*/
|
|
94
|
+
export const resolveToken = (token: TokenDefinition, theme: PaletteTheme): BaseResolvedColor => {
|
|
95
|
+
validateTokenDefinition(token);
|
|
96
|
+
return theme.resolve(token.query);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve all tokens in a registry while preserving key order.
|
|
101
|
+
*/
|
|
102
|
+
export const resolveTokenRegistry = (
|
|
103
|
+
registry: TokenRegistry,
|
|
104
|
+
theme: PaletteTheme,
|
|
105
|
+
): Record<string, BaseResolvedColor> => {
|
|
106
|
+
validateTokenRegistry(registry);
|
|
107
|
+
const resolved: Record<string, BaseResolvedColor> = {};
|
|
108
|
+
|
|
109
|
+
for (const [name, token] of Object.entries(registry.tokens)) {
|
|
110
|
+
resolved[name] = theme.resolve(token.query);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return resolved;
|
|
114
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { applyEmphasisOperator } from "../operators/emphasis.js";
|
|
2
|
+
import { applyStateOperator } from "../operators/state.js";
|
|
3
|
+
import { mapColorContextToEngine } from "./context.js";
|
|
4
|
+
import type { NormalizedQuery } from "./normalize.js";
|
|
5
|
+
import type { BaseResolvedColor, ThemeConfig } from "./resolveBaseColor.js";
|
|
6
|
+
|
|
7
|
+
export const applyOperators = (
|
|
8
|
+
base: BaseResolvedColor,
|
|
9
|
+
normalized: NormalizedQuery,
|
|
10
|
+
theme: ThemeConfig,
|
|
11
|
+
): BaseResolvedColor => {
|
|
12
|
+
const context = mapColorContextToEngine(normalized.context);
|
|
13
|
+
const operatorInput = {
|
|
14
|
+
oklch: base.oklch,
|
|
15
|
+
context,
|
|
16
|
+
surface: normalized.surface,
|
|
17
|
+
usage: normalized.usage,
|
|
18
|
+
state: normalized.state,
|
|
19
|
+
emphasis: normalized.emphasis,
|
|
20
|
+
preset: theme.preset,
|
|
21
|
+
step: base.step,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Emphasis defines the baseline hierarchy; state applies transient interaction.
|
|
25
|
+
const emphasized = applyEmphasisOperator(operatorInput);
|
|
26
|
+
const stated = applyStateOperator({ ...operatorInput, oklch: emphasized });
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
oklch: stated,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ColorContext } from "../types/index.js";
|
|
2
|
+
import type { NormalizedQuery } from "./normalize.js";
|
|
3
|
+
|
|
4
|
+
export type EngineContext = "light" | "dark";
|
|
5
|
+
|
|
6
|
+
export const mapColorContextToEngine = (
|
|
7
|
+
context: ColorContext | NormalizedQuery["context"],
|
|
8
|
+
): EngineContext => (context === "dark" || context === "dimmed" ? "dark" : "light");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { isInGamut, mapToGamut } from "./gamut.js";
|
|
4
|
+
import type { OkLchColor } from "./generateScale.js";
|
|
5
|
+
|
|
6
|
+
describe("gamut mapping", () => {
|
|
7
|
+
const outOfSrgbInP3: OkLchColor = { l: 60, c: 0.2, h: 40 };
|
|
8
|
+
|
|
9
|
+
it("detects out-of-gamut sRGB colors", () => {
|
|
10
|
+
expect(isInGamut(outOfSrgbInP3, "srgb")).toBe(false);
|
|
11
|
+
expect(isInGamut(outOfSrgbInP3, "p3")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("clips to sRGB gamut", () => {
|
|
15
|
+
const clipped = mapToGamut(outOfSrgbInP3, "srgb", "clip", false);
|
|
16
|
+
expect(isInGamut(clipped, "srgb")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("compresses chroma to sRGB gamut", () => {
|
|
20
|
+
const compressed = mapToGamut(outOfSrgbInP3, "srgb", "compressChroma", false);
|
|
21
|
+
expect(isInGamut(compressed, "srgb")).toBe(true);
|
|
22
|
+
expect(compressed.c).toBeLessThanOrEqual(outOfSrgbInP3.c);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps chroma when already in P3", () => {
|
|
26
|
+
const mapped = mapToGamut(outOfSrgbInP3, "p3", "preferP3ThenCompress", false);
|
|
27
|
+
expect(isInGamut(mapped, "p3")).toBe(true);
|
|
28
|
+
expect(mapped.c).toBeCloseTo(outOfSrgbInP3.c, 4);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { clampChroma, converter } from "culori";
|
|
2
|
+
|
|
3
|
+
import type { OutputOptions } from "../types/index.js";
|
|
4
|
+
import { clamp } from "../utils/clamp.js";
|
|
5
|
+
import type { OkLchColor } from "./generateScale.js";
|
|
6
|
+
|
|
7
|
+
export type GamutTarget = "srgb" | "p3";
|
|
8
|
+
export type GamutMapping = NonNullable<OutputOptions["gamutMapping"]>;
|
|
9
|
+
|
|
10
|
+
type GamutRgb = { r: number; g: number; b: number };
|
|
11
|
+
type CuloriOklch = { mode: "oklch"; l: number; c: number; h: number; alpha?: number };
|
|
12
|
+
|
|
13
|
+
const toRgb = converter("rgb");
|
|
14
|
+
const toP3 = converter("p3");
|
|
15
|
+
const toOklch = converter("oklch");
|
|
16
|
+
|
|
17
|
+
const normalizeHue = (hue: number) => ((hue % 360) + 360) % 360;
|
|
18
|
+
|
|
19
|
+
const toCuloriOklch = (color: OkLchColor): CuloriOklch => ({
|
|
20
|
+
mode: "oklch",
|
|
21
|
+
l: clamp(color.l, 0, 100) / 100,
|
|
22
|
+
c: Math.max(0, color.c),
|
|
23
|
+
h: color.h,
|
|
24
|
+
alpha: color.alpha ?? 1,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const fromCuloriOklch = (color: { l?: number; c?: number; h?: number; alpha?: number }) => ({
|
|
28
|
+
l: clamp(typeof color.l === "number" && Number.isFinite(color.l) ? color.l * 100 : 0, 0, 100),
|
|
29
|
+
c: Math.max(0, typeof color.c === "number" && Number.isFinite(color.c) ? color.c : 0),
|
|
30
|
+
h: normalizeHue(typeof color.h === "number" && Number.isFinite(color.h) ? color.h : 0),
|
|
31
|
+
...(typeof color.alpha === "number" ? { alpha: color.alpha } : {}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
|
|
35
|
+
|
|
36
|
+
const toTargetRgb = (color: OkLchColor, target: GamutTarget): GamutRgb | null => {
|
|
37
|
+
const source = toCuloriOklch(color);
|
|
38
|
+
const rgb = target === "p3" ? toP3(source) : toRgb(source);
|
|
39
|
+
|
|
40
|
+
if (!rgb) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const r = typeof rgb.r === "number" && Number.isFinite(rgb.r) ? rgb.r : Number.NaN;
|
|
45
|
+
const g = typeof rgb.g === "number" && Number.isFinite(rgb.g) ? rgb.g : Number.NaN;
|
|
46
|
+
const b = typeof rgb.b === "number" && Number.isFinite(rgb.b) ? rgb.b : Number.NaN;
|
|
47
|
+
|
|
48
|
+
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { r, g, b };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const toOklchFromRgb = (rgb: GamutRgb, target: GamutTarget) => {
|
|
56
|
+
const converted = toOklch({
|
|
57
|
+
mode: (target === "p3" ? "p3" : "rgb") as "p3" | "rgb",
|
|
58
|
+
r: clamp01(rgb.r),
|
|
59
|
+
g: clamp01(rgb.g),
|
|
60
|
+
b: clamp01(rgb.b),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!converted) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fromCuloriOklch(converted);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const GAMUT_EPSILON = 1e-6;
|
|
71
|
+
|
|
72
|
+
const inGamut = (rgb: GamutRgb | null) =>
|
|
73
|
+
!!rgb &&
|
|
74
|
+
Number.isFinite(rgb.r) &&
|
|
75
|
+
Number.isFinite(rgb.g) &&
|
|
76
|
+
Number.isFinite(rgb.b) &&
|
|
77
|
+
rgb.r >= -GAMUT_EPSILON &&
|
|
78
|
+
rgb.r <= 1 + GAMUT_EPSILON &&
|
|
79
|
+
rgb.g >= -GAMUT_EPSILON &&
|
|
80
|
+
rgb.g <= 1 + GAMUT_EPSILON &&
|
|
81
|
+
rgb.b >= -GAMUT_EPSILON &&
|
|
82
|
+
rgb.b <= 1 + GAMUT_EPSILON;
|
|
83
|
+
|
|
84
|
+
const fallbackColor = (color: OkLchColor): OkLchColor => ({
|
|
85
|
+
l: 0,
|
|
86
|
+
c: 0,
|
|
87
|
+
h: 0,
|
|
88
|
+
alpha: color.alpha ?? 1,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const isInGamut = (color: OkLchColor, target: GamutTarget): boolean =>
|
|
92
|
+
inGamut(toTargetRgb(color, target));
|
|
93
|
+
|
|
94
|
+
export const mapToGamut = (
|
|
95
|
+
color: OkLchColor,
|
|
96
|
+
target: GamutTarget,
|
|
97
|
+
mapping: GamutMapping,
|
|
98
|
+
strict: boolean,
|
|
99
|
+
): OkLchColor => {
|
|
100
|
+
// Note:
|
|
101
|
+
// `preferP3ThenCompress` is primarily a caller-level strategy (e.g. serializeColor prefers P3 when possible).
|
|
102
|
+
// Inside this mapper, any non-clip mapping uses chroma compression (via clampChroma) when mapping is needed.
|
|
103
|
+
|
|
104
|
+
const rgb = toTargetRgb(color, target);
|
|
105
|
+
|
|
106
|
+
if (!rgb) {
|
|
107
|
+
if (strict) {
|
|
108
|
+
throw new Error(`Unable to convert color to ${target}`);
|
|
109
|
+
}
|
|
110
|
+
return fallbackColor(color);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (mapping === "clip") {
|
|
114
|
+
if (inGamut(rgb)) {
|
|
115
|
+
return color;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const clipped = { r: clamp01(rgb.r), g: clamp01(rgb.g), b: clamp01(rgb.b) };
|
|
119
|
+
const clippedOklch = toOklchFromRgb(clipped, target);
|
|
120
|
+
return clippedOklch
|
|
121
|
+
? {
|
|
122
|
+
...clippedOklch,
|
|
123
|
+
...(typeof color.alpha === "number" ? { alpha: color.alpha } : {}),
|
|
124
|
+
}
|
|
125
|
+
: fallbackColor(color);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (inGamut(rgb)) {
|
|
129
|
+
return color;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const clamped = clampChroma(toCuloriOklch(color), "oklch", target === "p3" ? "p3" : "rgb");
|
|
133
|
+
if (!clamped) {
|
|
134
|
+
if (strict) {
|
|
135
|
+
throw new Error(`Unable to clamp chroma for ${target}`);
|
|
136
|
+
}
|
|
137
|
+
return fallbackColor(color);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return fromCuloriOklch(clamped);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const toGamutRgb = (color: OkLchColor, target: GamutTarget): GamutRgb | null =>
|
|
144
|
+
toTargetRgb(color, target);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { generateScale } from "./generateScale.js";
|
|
4
|
+
|
|
5
|
+
const seed = { l: 49.5, c: 0.19, h: 264.0 };
|
|
6
|
+
|
|
7
|
+
const isMonotonic = (values: number[]) =>
|
|
8
|
+
values.every((value, index) => index === 0 || value >= values[index - 1] - 1e-6);
|
|
9
|
+
|
|
10
|
+
describe("generateScale", () => {
|
|
11
|
+
it("returns 12 steps", () => {
|
|
12
|
+
const result = generateScale(seed, { context: "light", surface: "surface" });
|
|
13
|
+
|
|
14
|
+
expect(result).toHaveLength(12);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps lightness monotonic in light context", () => {
|
|
18
|
+
const result = generateScale(seed, { context: "light", surface: "surface" });
|
|
19
|
+
const lightness = result.map((step) => step.l);
|
|
20
|
+
|
|
21
|
+
expect(isMonotonic(lightness)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps lightness monotonic in dark context", () => {
|
|
25
|
+
const result = generateScale(seed, { context: "dark", surface: "surface" });
|
|
26
|
+
const lightness = result.map((step) => step.l);
|
|
27
|
+
|
|
28
|
+
expect(isMonotonic(lightness)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps hue constant across steps", () => {
|
|
32
|
+
const result = generateScale(seed, { context: "light", surface: "surface" });
|
|
33
|
+
|
|
34
|
+
result.forEach((step) => {
|
|
35
|
+
expect(step.h).toBeCloseTo(seed.h, 6);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never produces negative chroma", () => {
|
|
40
|
+
const result = generateScale(seed, { context: "light", surface: "surface" });
|
|
41
|
+
|
|
42
|
+
result.forEach((step) => {
|
|
43
|
+
expect(step.c).toBeGreaterThanOrEqual(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CurvePresetName } from "../presets/index.js";
|
|
2
|
+
import { curvePresets } from "../presets/index.js";
|
|
3
|
+
import type { SurfaceIntent } from "../types/index.js";
|
|
4
|
+
import { clamp } from "../utils/clamp.js";
|
|
5
|
+
import { lerp } from "../utils/lerp.js";
|
|
6
|
+
|
|
7
|
+
export type OkLchColor = {
|
|
8
|
+
l: number;
|
|
9
|
+
c: number;
|
|
10
|
+
h: number;
|
|
11
|
+
alpha?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GenerateScaleOptions = {
|
|
15
|
+
context: "light" | "dark";
|
|
16
|
+
surface: SurfaceIntent;
|
|
17
|
+
preset?: CurvePresetName;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const STEPS = 12;
|
|
21
|
+
const OKLCH_L_MIN = 0;
|
|
22
|
+
const OKLCH_L_MAX = 100;
|
|
23
|
+
|
|
24
|
+
export function generateScale(seed: OkLchColor, options: GenerateScaleOptions): OkLchColor[] {
|
|
25
|
+
const presetName = options.preset ?? "modern";
|
|
26
|
+
const preset = curvePresets[presetName];
|
|
27
|
+
const surfaceCurve = preset.surfaces[options.surface];
|
|
28
|
+
const range = surfaceCurve.ranges[options.context];
|
|
29
|
+
|
|
30
|
+
return Array.from({ length: STEPS }, (_, index) => {
|
|
31
|
+
const t = index / (STEPS - 1);
|
|
32
|
+
const lightnessT = surfaceCurve.l(t);
|
|
33
|
+
const chromaT = surfaceCurve.c(lightnessT);
|
|
34
|
+
const l = clamp(lerp(range.l[0], range.l[1], lightnessT), OKLCH_L_MIN, OKLCH_L_MAX);
|
|
35
|
+
// Allow select surfaces to exceed seed chroma slightly while staying within range caps.
|
|
36
|
+
const chromaBoost = range.chromaBoost ?? 1;
|
|
37
|
+
const boostedSeed = (seed.c ?? range.cMax) * chromaBoost;
|
|
38
|
+
const maxChroma = Math.min(range.cMax, Math.max(0, boostedSeed));
|
|
39
|
+
const c = clamp(lerp(range.cMin, maxChroma, chromaT), 0, range.cMax);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
l,
|
|
43
|
+
c,
|
|
44
|
+
h: seed.h,
|
|
45
|
+
alpha: seed.alpha,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { EngineContext } from "./context.js";
|
|
2
|
+
export { mapColorContextToEngine } from "./context.js";
|
|
3
|
+
export type { OkLchColor } from "./generateScale.js";
|
|
4
|
+
export { generateScale } from "./generateScale.js";
|
|
5
|
+
export type { NormalizedOnSolidQuery, NormalizedQuery } from "./normalize.js";
|
|
6
|
+
export { normalizeOnSolidQuery, normalizeQuery } from "./normalize.js";
|
|
7
|
+
export type { BaseResolvedColor, ThemeConfig } from "./resolveBaseColor.js";
|
|
8
|
+
export { resolveBaseColor } from "./resolveBaseColor.js";
|