@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.
- 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 -20
- package/dist/index.js +2 -17
- 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 -137
- 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/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,148 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { scoreApca, scoreContrast } from "./scoring.js";
|
|
3
|
+
describe("scoreApca", () => {
|
|
4
|
+
it("prefers values closer to target within range", () => {
|
|
5
|
+
const target = 60;
|
|
6
|
+
const min = 50;
|
|
7
|
+
const max = 80;
|
|
8
|
+
const hasMax = true;
|
|
9
|
+
expect(scoreApca(60, target, min, max, hasMax)).toBeGreaterThan(scoreApca(80, target, min, max, hasMax));
|
|
10
|
+
});
|
|
11
|
+
it("penalizes values outside the range", () => {
|
|
12
|
+
const target = 60;
|
|
13
|
+
const min = 50;
|
|
14
|
+
const max = 80;
|
|
15
|
+
const hasMax = true;
|
|
16
|
+
expect(scoreApca(40, target, min, max, hasMax)).toBeLessThan(0);
|
|
17
|
+
expect(scoreApca(90, target, min, max, hasMax)).toBeLessThan(0);
|
|
18
|
+
});
|
|
19
|
+
it("penalizes below min and prefers target when no max", () => {
|
|
20
|
+
const target = 60;
|
|
21
|
+
const min = 50;
|
|
22
|
+
const max = Number.POSITIVE_INFINITY;
|
|
23
|
+
const hasMax = false;
|
|
24
|
+
expect(scoreApca(40, target, min, max, hasMax)).toBeLessThan(scoreApca(55, target, min, max, hasMax));
|
|
25
|
+
expect(scoreApca(60, target, min, max, hasMax)).toBeGreaterThan(scoreApca(70, target, min, max, hasMax));
|
|
26
|
+
});
|
|
27
|
+
it("returns NEGATIVE_INFINITY for NaN values", () => {
|
|
28
|
+
const target = 60;
|
|
29
|
+
const min = 50;
|
|
30
|
+
const max = 80;
|
|
31
|
+
const hasMax = true;
|
|
32
|
+
expect(scoreApca(Number.NaN, target, min, max, hasMax)).toBe(Number.NEGATIVE_INFINITY);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("scoreContrast", () => {
|
|
36
|
+
describe("with APCA contrast model", () => {
|
|
37
|
+
it("delegates to scoreApca with correct parameters when maxLc is defined", () => {
|
|
38
|
+
const result = {
|
|
39
|
+
model: "apca",
|
|
40
|
+
target: 60,
|
|
41
|
+
value: 65,
|
|
42
|
+
pass: true,
|
|
43
|
+
};
|
|
44
|
+
const req = {
|
|
45
|
+
model: "apca",
|
|
46
|
+
targetLc: 60,
|
|
47
|
+
minLc: 50,
|
|
48
|
+
maxLc: 80,
|
|
49
|
+
};
|
|
50
|
+
const score = scoreContrast(result, req);
|
|
51
|
+
const expectedScore = scoreApca(65, 60, 50, 80, true);
|
|
52
|
+
expect(score).toBe(expectedScore);
|
|
53
|
+
});
|
|
54
|
+
it("delegates to scoreApca without max when maxLc is undefined", () => {
|
|
55
|
+
const result = {
|
|
56
|
+
model: "apca",
|
|
57
|
+
target: 60,
|
|
58
|
+
value: 65,
|
|
59
|
+
pass: true,
|
|
60
|
+
};
|
|
61
|
+
const req = {
|
|
62
|
+
model: "apca",
|
|
63
|
+
targetLc: 60,
|
|
64
|
+
minLc: 50,
|
|
65
|
+
};
|
|
66
|
+
const score = scoreContrast(result, req);
|
|
67
|
+
const expectedScore = scoreApca(65, 60, 50, Number.POSITIVE_INFINITY, false);
|
|
68
|
+
expect(score).toBe(expectedScore);
|
|
69
|
+
});
|
|
70
|
+
it("uses targetLc as min when minLc is undefined", () => {
|
|
71
|
+
const result = {
|
|
72
|
+
model: "apca",
|
|
73
|
+
target: 60,
|
|
74
|
+
value: 65,
|
|
75
|
+
pass: true,
|
|
76
|
+
};
|
|
77
|
+
const req = {
|
|
78
|
+
model: "apca",
|
|
79
|
+
targetLc: 60,
|
|
80
|
+
};
|
|
81
|
+
const score = scoreContrast(result, req);
|
|
82
|
+
const expectedScore = scoreApca(65, 60, 60, Number.POSITIVE_INFINITY, false);
|
|
83
|
+
expect(score).toBe(expectedScore);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("with WCAG2 contrast model", () => {
|
|
87
|
+
it("returns the raw contrast value", () => {
|
|
88
|
+
const result = {
|
|
89
|
+
model: "wcag2",
|
|
90
|
+
target: 4.5,
|
|
91
|
+
value: 7.2,
|
|
92
|
+
pass: true,
|
|
93
|
+
};
|
|
94
|
+
const req = {
|
|
95
|
+
model: "wcag2",
|
|
96
|
+
minRatio: 4.5,
|
|
97
|
+
};
|
|
98
|
+
const score = scoreContrast(result, req);
|
|
99
|
+
expect(score).toBe(7.2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("with NaN or non-finite values", () => {
|
|
103
|
+
it("returns NEGATIVE_INFINITY for NaN", () => {
|
|
104
|
+
const result = {
|
|
105
|
+
model: "apca",
|
|
106
|
+
target: 60,
|
|
107
|
+
value: Number.NaN,
|
|
108
|
+
pass: false,
|
|
109
|
+
};
|
|
110
|
+
const req = {
|
|
111
|
+
model: "apca",
|
|
112
|
+
targetLc: 60,
|
|
113
|
+
minLc: 50,
|
|
114
|
+
};
|
|
115
|
+
const score = scoreContrast(result, req);
|
|
116
|
+
expect(score).toBe(Number.NEGATIVE_INFINITY);
|
|
117
|
+
});
|
|
118
|
+
it("returns NEGATIVE_INFINITY for positive infinity", () => {
|
|
119
|
+
const result = {
|
|
120
|
+
model: "apca",
|
|
121
|
+
target: 60,
|
|
122
|
+
value: Number.POSITIVE_INFINITY,
|
|
123
|
+
pass: false,
|
|
124
|
+
};
|
|
125
|
+
const req = {
|
|
126
|
+
model: "apca",
|
|
127
|
+
targetLc: 60,
|
|
128
|
+
minLc: 50,
|
|
129
|
+
};
|
|
130
|
+
const score = scoreContrast(result, req);
|
|
131
|
+
expect(score).toBe(Number.NEGATIVE_INFINITY);
|
|
132
|
+
});
|
|
133
|
+
it("returns NEGATIVE_INFINITY for negative infinity", () => {
|
|
134
|
+
const result = {
|
|
135
|
+
model: "wcag2",
|
|
136
|
+
target: 4.5,
|
|
137
|
+
value: Number.NEGATIVE_INFINITY,
|
|
138
|
+
pass: false,
|
|
139
|
+
};
|
|
140
|
+
const req = {
|
|
141
|
+
model: "wcag2",
|
|
142
|
+
minRatio: 4.5,
|
|
143
|
+
};
|
|
144
|
+
const score = scoreContrast(result, req);
|
|
145
|
+
expect(score).toBe(Number.NEGATIVE_INFINITY);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OkLchColor } from "../engine/generateScale.js";
|
|
2
|
+
import type { CurvePresetName } from "../presets/index.js";
|
|
3
|
+
import type { ContrastRequirement, SurfaceIntent } from "../types/index.js";
|
|
4
|
+
import type { ContrastCheckResult, SolveOptions } from "./types.js";
|
|
5
|
+
export declare function solveContrast(fg: OkLchColor, bg: OkLchColor | undefined, req: ContrastRequirement, ctx: {
|
|
6
|
+
preset?: CurvePresetName;
|
|
7
|
+
surface: SurfaceIntent;
|
|
8
|
+
context: "light" | "dark";
|
|
9
|
+
}, opts?: SolveOptions): {
|
|
10
|
+
color: OkLchColor;
|
|
11
|
+
result: ContrastCheckResult;
|
|
12
|
+
iterations: number;
|
|
13
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { converter } from "culori";
|
|
2
|
+
import { getSurfaceRange } from "../operators/utils.js";
|
|
3
|
+
import { computeApcaLc } from "./apca.js";
|
|
4
|
+
import { scoreContrast } from "./scoring.js";
|
|
5
|
+
import { contrastRatio } from "./wcag2.js";
|
|
6
|
+
const toSrgb = converter("rgb");
|
|
7
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
8
|
+
const clampOkLch = (color, cMax) => ({
|
|
9
|
+
l: clamp(color.l, 0, 100),
|
|
10
|
+
c: clamp(color.c, 0, cMax),
|
|
11
|
+
h: color.h,
|
|
12
|
+
alpha: color.alpha,
|
|
13
|
+
});
|
|
14
|
+
const clampOkLchLoose = (color) => ({
|
|
15
|
+
l: clamp(color.l, 0, 100),
|
|
16
|
+
c: Math.max(0, color.c),
|
|
17
|
+
h: color.h,
|
|
18
|
+
alpha: color.alpha,
|
|
19
|
+
});
|
|
20
|
+
const toSrgbColor = (color) => {
|
|
21
|
+
const rgb = toSrgb({ mode: "oklch", l: clamp(color.l, 0, 100) / 100, c: color.c, h: color.h });
|
|
22
|
+
if (!rgb) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const r = typeof rgb.r === "number" && Number.isFinite(rgb.r) ? clamp(rgb.r, 0, 1) : 0;
|
|
26
|
+
const g = typeof rgb.g === "number" && Number.isFinite(rgb.g) ? clamp(rgb.g, 0, 1) : 0;
|
|
27
|
+
const b = typeof rgb.b === "number" && Number.isFinite(rgb.b) ? clamp(rgb.b, 0, 1) : 0;
|
|
28
|
+
return { r, g, b };
|
|
29
|
+
};
|
|
30
|
+
const getTarget = (req) => {
|
|
31
|
+
if (req.model === "apca") {
|
|
32
|
+
return req.targetLc;
|
|
33
|
+
}
|
|
34
|
+
if (req.model === "wcag2") {
|
|
35
|
+
return req.minRatio;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
};
|
|
39
|
+
const checkContrast = (fg, bg, req, epsilon) => {
|
|
40
|
+
if (req.model === "none") {
|
|
41
|
+
return { model: "none", target: 0, value: 0, pass: true };
|
|
42
|
+
}
|
|
43
|
+
const fgSrgb = toSrgbColor(fg);
|
|
44
|
+
const bgSrgb = toSrgbColor(bg);
|
|
45
|
+
const target = getTarget(req);
|
|
46
|
+
if (!fgSrgb || !bgSrgb) {
|
|
47
|
+
return { model: req.model, target, value: Number.NaN, pass: false };
|
|
48
|
+
}
|
|
49
|
+
if (req.model === "apca") {
|
|
50
|
+
const value = Math.abs(computeApcaLc(fgSrgb, bgSrgb));
|
|
51
|
+
const minTarget = req.minLc ?? req.targetLc;
|
|
52
|
+
const maxTarget = req.maxLc ?? Number.POSITIVE_INFINITY;
|
|
53
|
+
const pass = Number.isFinite(value) && value >= minTarget - epsilon && value <= maxTarget + epsilon;
|
|
54
|
+
return { model: "apca", target, value, pass };
|
|
55
|
+
}
|
|
56
|
+
const value = contrastRatio(fgSrgb, bgSrgb);
|
|
57
|
+
const pass = Number.isFinite(value) && value + epsilon >= target;
|
|
58
|
+
return { model: "wcag2", target, value, pass };
|
|
59
|
+
};
|
|
60
|
+
const pickBetter = (current, candidate, req) => {
|
|
61
|
+
const currentScore = scoreContrast(current.result, req);
|
|
62
|
+
const candidateScore = scoreContrast(candidate.result, req);
|
|
63
|
+
return candidateScore > currentScore ? candidate : current;
|
|
64
|
+
};
|
|
65
|
+
export function solveContrast(fg, bg, req, ctx, opts) {
|
|
66
|
+
const options = {
|
|
67
|
+
strict: false,
|
|
68
|
+
maxIterations: 24,
|
|
69
|
+
epsilon: 0.01,
|
|
70
|
+
...opts,
|
|
71
|
+
};
|
|
72
|
+
if (req.model === "none") {
|
|
73
|
+
return {
|
|
74
|
+
color: fg,
|
|
75
|
+
result: { model: "none", target: 0, value: 0, pass: true },
|
|
76
|
+
iterations: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!bg) {
|
|
80
|
+
if (options.strict) {
|
|
81
|
+
throw new Error("Contrast solver requires background");
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
color: fg,
|
|
85
|
+
result: { model: req.model, target: getTarget(req), value: Number.NaN, pass: false },
|
|
86
|
+
iterations: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const range = getSurfaceRange(ctx.preset, ctx.surface, ctx.context);
|
|
90
|
+
const clamped = clampOkLch(fg, range.cMax);
|
|
91
|
+
const background = clampOkLchLoose(bg);
|
|
92
|
+
let iterations = 0;
|
|
93
|
+
const evaluate = (color) => {
|
|
94
|
+
const result = checkContrast(color, background, req, options.epsilon);
|
|
95
|
+
iterations += 1;
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
let best = { color: clamped, result: evaluate(clamped) };
|
|
99
|
+
if (best.result.pass) {
|
|
100
|
+
return { ...best, iterations };
|
|
101
|
+
}
|
|
102
|
+
const lMin = 0;
|
|
103
|
+
const lMax = 100;
|
|
104
|
+
const sampleT = 0.25;
|
|
105
|
+
const sampleDown = clamp(clamped.l + (lMin - clamped.l) * sampleT, lMin, lMax);
|
|
106
|
+
const sampleUp = clamp(clamped.l + (lMax - clamped.l) * sampleT, lMin, lMax);
|
|
107
|
+
let preferredBound = lMin;
|
|
108
|
+
if (iterations < options.maxIterations) {
|
|
109
|
+
const downCandidate = {
|
|
110
|
+
color: { ...clamped, l: sampleDown },
|
|
111
|
+
result: evaluate({ ...clamped, l: sampleDown }),
|
|
112
|
+
};
|
|
113
|
+
best = pickBetter(best, downCandidate, req);
|
|
114
|
+
if (best.result.pass) {
|
|
115
|
+
return { ...best, iterations };
|
|
116
|
+
}
|
|
117
|
+
if (iterations < options.maxIterations) {
|
|
118
|
+
const upCandidate = {
|
|
119
|
+
color: { ...clamped, l: sampleUp },
|
|
120
|
+
result: evaluate({ ...clamped, l: sampleUp }),
|
|
121
|
+
};
|
|
122
|
+
best = pickBetter(best, upCandidate, req);
|
|
123
|
+
if (best.result.pass) {
|
|
124
|
+
return { ...best, iterations };
|
|
125
|
+
}
|
|
126
|
+
preferredBound =
|
|
127
|
+
scoreContrast(upCandidate.result, req) > scoreContrast(downCandidate.result, req)
|
|
128
|
+
? lMax
|
|
129
|
+
: lMin;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const remainingAfterSamples = Math.max(0, options.maxIterations - iterations);
|
|
133
|
+
const lSteps = Math.max(4, remainingAfterSamples);
|
|
134
|
+
for (let step = 1; step <= lSteps && iterations < options.maxIterations; step += 1) {
|
|
135
|
+
const t = step / lSteps;
|
|
136
|
+
const l = clamp(clamped.l + (preferredBound - clamped.l) * t, lMin, lMax);
|
|
137
|
+
const candidateColor = { ...clamped, l };
|
|
138
|
+
const result = evaluate(candidateColor);
|
|
139
|
+
const candidate = { color: candidateColor, result };
|
|
140
|
+
best = pickBetter(best, candidate, req);
|
|
141
|
+
if (result.pass) {
|
|
142
|
+
return { color: candidateColor, result, iterations };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let current = { ...best.color };
|
|
146
|
+
while (iterations < options.maxIterations && current.c > 0) {
|
|
147
|
+
const nextC = clamp(current.c * 0.9, 0, range.cMax);
|
|
148
|
+
current = { ...current, c: nextC };
|
|
149
|
+
const result = evaluate(current);
|
|
150
|
+
best = pickBetter(best, { color: current, result }, req);
|
|
151
|
+
if (result.pass) {
|
|
152
|
+
return { color: current, result, iterations };
|
|
153
|
+
}
|
|
154
|
+
const sweepSteps = Math.min(3, options.maxIterations - iterations);
|
|
155
|
+
for (let step = 1; step <= sweepSteps && iterations < options.maxIterations; step += 1) {
|
|
156
|
+
const t = step / sweepSteps;
|
|
157
|
+
const l = clamp(current.l + (preferredBound - current.l) * t, lMin, lMax);
|
|
158
|
+
const candidateColor = { ...current, l };
|
|
159
|
+
const candidateResult = evaluate(candidateColor);
|
|
160
|
+
best = pickBetter(best, { color: candidateColor, result: candidateResult }, req);
|
|
161
|
+
if (candidateResult.pass) {
|
|
162
|
+
return { color: candidateColor, result: candidateResult, iterations };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (options.strict) {
|
|
167
|
+
throw new Error(`Contrast solver failed (${best.result.model}) target=${best.result.target} value=${best.result.value} iterations=${iterations}`);
|
|
168
|
+
}
|
|
169
|
+
return { color: best.color, result: best.result, iterations };
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseColor } from "../utils/parseColor.js";
|
|
3
|
+
import { solveContrast } from "./solver.js";
|
|
4
|
+
const toOkLch = (hex) => {
|
|
5
|
+
const parsed = parseColor(hex);
|
|
6
|
+
return {
|
|
7
|
+
l: parsed.okLch.channels[0] * 100,
|
|
8
|
+
c: parsed.okLch.channels[1],
|
|
9
|
+
h: parsed.okLch.channels[2],
|
|
10
|
+
alpha: parsed.okLch.alpha,
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
describe("solveContrast", () => {
|
|
14
|
+
it("raises contrast on light backgrounds", () => {
|
|
15
|
+
const fg = toOkLch("#777777");
|
|
16
|
+
const bg = toOkLch("#ffffff");
|
|
17
|
+
const result = solveContrast(fg, bg, { model: "wcag2", minRatio: 4.5 }, { surface: "surface", context: "light" });
|
|
18
|
+
expect(result.result.pass).toBe(true);
|
|
19
|
+
expect(result.result.value).toBeGreaterThanOrEqual(4.5);
|
|
20
|
+
expect(result.color.l).toBeGreaterThanOrEqual(0);
|
|
21
|
+
expect(result.color.l).toBeLessThanOrEqual(100);
|
|
22
|
+
expect(result.color.c).toBeGreaterThanOrEqual(0);
|
|
23
|
+
});
|
|
24
|
+
it("returns original color when contrast model is none", () => {
|
|
25
|
+
const fg = toOkLch("#777777");
|
|
26
|
+
const bg = toOkLch("#ffffff");
|
|
27
|
+
const result = solveContrast(fg, bg, { model: "none" }, { surface: "surface", context: "light" });
|
|
28
|
+
expect(result.result.pass).toBe(true);
|
|
29
|
+
expect(result.iterations).toBe(0);
|
|
30
|
+
expect(result.color).toEqual(fg);
|
|
31
|
+
});
|
|
32
|
+
it("skips when background is missing and strict is false", () => {
|
|
33
|
+
const fg = toOkLch("#777777");
|
|
34
|
+
const result = solveContrast(fg, undefined, { model: "wcag2", minRatio: 4.5 }, { surface: "surface", context: "light" }, { strict: false });
|
|
35
|
+
expect(result.result.pass).toBe(false);
|
|
36
|
+
expect(Number.isNaN(result.result.value)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("throws when background is missing and strict is true", () => {
|
|
39
|
+
const fg = toOkLch("#777777");
|
|
40
|
+
expect(() => solveContrast(fg, undefined, { model: "wcag2", minRatio: 4.5 }, { surface: "surface", context: "light" }, { strict: true })).toThrowError(/requires background/i);
|
|
41
|
+
});
|
|
42
|
+
it("raises contrast on dark backgrounds", () => {
|
|
43
|
+
const fg = toOkLch("#777777");
|
|
44
|
+
const bg = toOkLch("#111111");
|
|
45
|
+
const result = solveContrast(fg, bg, { model: "wcag2", minRatio: 4.5 }, { surface: "surface", context: "dark" });
|
|
46
|
+
expect(result.result.pass).toBe(true);
|
|
47
|
+
expect(result.result.value).toBeGreaterThanOrEqual(4.5);
|
|
48
|
+
});
|
|
49
|
+
it("keeps hue stable", () => {
|
|
50
|
+
const fg = toOkLch("#3366ff");
|
|
51
|
+
const bg = toOkLch("#ffffff");
|
|
52
|
+
const result = solveContrast(fg, bg, { model: "wcag2", minRatio: 4.5 }, { surface: "surface", context: "light" });
|
|
53
|
+
expect(result.color.h).toBeCloseTo(fg.h, 6);
|
|
54
|
+
});
|
|
55
|
+
it("throws in strict mode when target is unattainable", () => {
|
|
56
|
+
const fg = toOkLch("#ffffff");
|
|
57
|
+
const bg = toOkLch("#ffffff");
|
|
58
|
+
expect(() => solveContrast(fg, bg, { model: "wcag2", minRatio: 30 }, { surface: "surface", context: "light" }, { strict: true })).toThrowError(/contrast solver failed/i);
|
|
59
|
+
});
|
|
60
|
+
it("prefers APCA values closer to target within the allowed range", () => {
|
|
61
|
+
const fg = toOkLch("#777777");
|
|
62
|
+
const bg = toOkLch("#ffffff");
|
|
63
|
+
const minLc = 50;
|
|
64
|
+
const maxLc = 80;
|
|
65
|
+
const targetLc = 60;
|
|
66
|
+
const result = solveContrast(fg, bg, { model: "apca", targetLc, minLc, maxLc }, { surface: "surface", context: "light" });
|
|
67
|
+
const value = result.result.value;
|
|
68
|
+
const targetDistance = Math.abs(value - targetLc);
|
|
69
|
+
const maxDistance = Math.abs(maxLc - targetLc);
|
|
70
|
+
expect(result.result.pass).toBe(true);
|
|
71
|
+
expect(value).toBeGreaterThanOrEqual(minLc);
|
|
72
|
+
expect(value).toBeLessThanOrEqual(maxLc);
|
|
73
|
+
expect(targetDistance).toBeLessThanOrEqual(maxDistance);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ContrastModel = "apca" | "wcag2" | "none";
|
|
2
|
+
export type ContrastCheckResult = {
|
|
3
|
+
model: ContrastModel;
|
|
4
|
+
target: number;
|
|
5
|
+
value: number;
|
|
6
|
+
pass: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type SolveOptions = {
|
|
9
|
+
strict?: boolean;
|
|
10
|
+
maxIterations?: number;
|
|
11
|
+
epsilon?: number;
|
|
12
|
+
};
|
|
13
|
+
export type SrgbColor = {
|
|
14
|
+
r: number;
|
|
15
|
+
g: number;
|
|
16
|
+
b: number;
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OkLchColor } from "../engine/generateScale.js";
|
|
2
|
+
import type { SrgbColor } from "./types.js";
|
|
3
|
+
export declare const toSrgbColor: (color: OkLchColor) => SrgbColor | null;
|
|
4
|
+
export declare const blendSrgb: (fg: SrgbColor, bg: SrgbColor, alpha: number) => SrgbColor;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { converter } from "culori";
|
|
2
|
+
const toSrgb = converter("rgb");
|
|
3
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
4
|
+
export const toSrgbColor = (color) => {
|
|
5
|
+
const rgb = toSrgb({ mode: "oklch", l: clamp(color.l, 0, 100) / 100, c: color.c, h: color.h });
|
|
6
|
+
if (!rgb) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const r = typeof rgb.r === "number" && Number.isFinite(rgb.r) ? clamp(rgb.r, 0, 1) : 0;
|
|
10
|
+
const g = typeof rgb.g === "number" && Number.isFinite(rgb.g) ? clamp(rgb.g, 0, 1) : 0;
|
|
11
|
+
const b = typeof rgb.b === "number" && Number.isFinite(rgb.b) ? clamp(rgb.b, 0, 1) : 0;
|
|
12
|
+
return { r, g, b };
|
|
13
|
+
};
|
|
14
|
+
export const blendSrgb = (fg, bg, alpha) => ({
|
|
15
|
+
r: fg.r * alpha + bg.r * (1 - alpha),
|
|
16
|
+
g: fg.g * alpha + bg.g * (1 - alpha),
|
|
17
|
+
b: fg.b * alpha + bg.b * (1 - alpha),
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
2
|
+
const toLinear = (channel) => {
|
|
3
|
+
const value = clamp01(channel);
|
|
4
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
5
|
+
};
|
|
6
|
+
export const relativeLuminance = (color) => {
|
|
7
|
+
const r = toLinear(color.r);
|
|
8
|
+
const g = toLinear(color.g);
|
|
9
|
+
const b = toLinear(color.b);
|
|
10
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
11
|
+
};
|
|
12
|
+
export const contrastRatio = (fg, bg) => {
|
|
13
|
+
const fgLum = relativeLuminance(fg);
|
|
14
|
+
const bgLum = relativeLuminance(bg);
|
|
15
|
+
const lighter = Math.max(fgLum, bgLum);
|
|
16
|
+
const darker = Math.min(fgLum, bgLum);
|
|
17
|
+
const ratio = (lighter + 0.05) / (darker + 0.05);
|
|
18
|
+
return Number.isFinite(ratio) ? ratio : Number.NaN;
|
|
19
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { contrastRatio } from "./wcag2.js";
|
|
3
|
+
describe("wcag2 contrastRatio", () => {
|
|
4
|
+
it("returns high contrast for black on white", () => {
|
|
5
|
+
const ratio = contrastRatio({ r: 0, g: 0, b: 0 }, { r: 1, g: 1, b: 1 });
|
|
6
|
+
expect(ratio).toBeGreaterThan(10);
|
|
7
|
+
});
|
|
8
|
+
it("returns lower contrast for mid gray on white", () => {
|
|
9
|
+
const gray = 0x77 / 255;
|
|
10
|
+
const ratio = contrastRatio({ r: gray, g: gray, b: gray }, { r: 1, g: 1, b: 1 });
|
|
11
|
+
expect(ratio).toBeLessThan(4.5);
|
|
12
|
+
});
|
|
13
|
+
it("does not return NaN", () => {
|
|
14
|
+
const ratio = contrastRatio({ r: 0.2, g: 0.4, b: 0.6 }, { r: 0.8, g: 0.7, b: 0.1 });
|
|
15
|
+
expect(Number.isNaN(ratio)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BaseResolvedColor, ThemeConfig } from "../engine/resolveBaseColor.js";
|
|
2
|
+
import type { ColorContext, ColorQuery, ColorRole, OnSolidQuery, OutputOptions, ResolvedColor } from "../types/index.js";
|
|
3
|
+
export type PaletteTheme = {
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a single color query to the core OKLCH output shape.
|
|
6
|
+
*/
|
|
7
|
+
resolve: (query: ColorQuery) => BaseResolvedColor;
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a batch of color queries while preserving input order.
|
|
10
|
+
*/
|
|
11
|
+
resolveMany: (queries: ColorQuery[]) => BaseResolvedColor[];
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a color role with inference and DX validation.
|
|
14
|
+
*
|
|
15
|
+
* Inference and strict/non-strict behavior is shared with `theme.resolve(...)` and is
|
|
16
|
+
* implemented inside query normalization.
|
|
17
|
+
*
|
|
18
|
+
* When `output.strict` is true, missing inference throws an error; otherwise
|
|
19
|
+
* safe defaults are used.
|
|
20
|
+
*/
|
|
21
|
+
color: (role: ColorRole, options?: Omit<ColorQuery, "role">) => BaseResolvedColor;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a foreground color against a solid background (APCA/WCAG aware).
|
|
24
|
+
*/
|
|
25
|
+
onSolid: (query: OnSolidQuery) => BaseResolvedColor;
|
|
26
|
+
/**
|
|
27
|
+
* Serialize a resolved color query for external outputs (CSS, RN, JSON, etc.).
|
|
28
|
+
*/
|
|
29
|
+
serialize: (query: ColorQuery, options?: OutputOptions) => ResolvedColor;
|
|
30
|
+
/**
|
|
31
|
+
* Return a new theme instance with a bound context.
|
|
32
|
+
*/
|
|
33
|
+
withContext: (context: ColorContext) => PaletteTheme;
|
|
34
|
+
};
|
|
35
|
+
export declare function createTheme(config: ThemeConfig): PaletteTheme;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { onSolid } from "../engine/onSolid.js";
|
|
2
|
+
import { serializeResolved } from "../serialize/serializeColor.js";
|
|
3
|
+
import { resolve } from "./resolve.js";
|
|
4
|
+
import { resolveMany } from "./resolveMany.js";
|
|
5
|
+
export function createTheme(config) {
|
|
6
|
+
const themeConfig = {
|
|
7
|
+
...config,
|
|
8
|
+
preset: config.preset ?? "modern",
|
|
9
|
+
variants: config.variants ?? {},
|
|
10
|
+
};
|
|
11
|
+
const applyBoundContext = (query, boundContext) => (boundContext ? { context: boundContext, ...query } : query);
|
|
12
|
+
const buildTheme = (boundContext) => ({
|
|
13
|
+
resolve: (query) => resolve(applyBoundContext(query, boundContext), themeConfig),
|
|
14
|
+
resolveMany: (queries) => resolveMany(queries.map((query) => applyBoundContext(query, boundContext)), themeConfig),
|
|
15
|
+
color: (role, options) => resolve(applyBoundContext({ role, ...(options ?? {}) }, boundContext), themeConfig),
|
|
16
|
+
onSolid: (query) => onSolid(applyBoundContext(query, boundContext), themeConfig),
|
|
17
|
+
serialize: (query, options) => {
|
|
18
|
+
const resolved = resolve(applyBoundContext(query, boundContext), themeConfig);
|
|
19
|
+
return serializeResolved(resolved, options);
|
|
20
|
+
},
|
|
21
|
+
withContext: (context) => buildTheme(context),
|
|
22
|
+
});
|
|
23
|
+
return buildTheme();
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTheme } from "./createTheme.js";
|
|
3
|
+
describe("Phase 2 helpers", () => {
|
|
4
|
+
const theme = createTheme({
|
|
5
|
+
seeds: {
|
|
6
|
+
light: { neutral: "#8B8D98", accent: "#3D63DD" },
|
|
7
|
+
dark: { neutral: "#8B8D98", accent: "#3D63DD" },
|
|
8
|
+
},
|
|
9
|
+
preset: "modern",
|
|
10
|
+
});
|
|
11
|
+
it("resolveMany preserves input order", () => {
|
|
12
|
+
const queries = [
|
|
13
|
+
{ role: "bg.app", usage: "bg", surface: "app", context: "light" },
|
|
14
|
+
{ role: "text.primary", usage: "text", surface: "surface", context: "light" },
|
|
15
|
+
];
|
|
16
|
+
const expected = queries.map((query) => theme.resolve(query));
|
|
17
|
+
const results = theme.resolveMany(queries);
|
|
18
|
+
expect(results.map((result) => result.step)).toEqual(expected.map((result) => result.step));
|
|
19
|
+
expect(results.map((result) => result.seedUsed)).toEqual(expected.map((result) => result.seedUsed));
|
|
20
|
+
});
|
|
21
|
+
it("withContext applies bound context and allows overrides", () => {
|
|
22
|
+
const darkTheme = theme.withContext("dark");
|
|
23
|
+
const bound = darkTheme.resolve({ role: "bg.app", usage: "bg", surface: "app" });
|
|
24
|
+
const explicit = {
|
|
25
|
+
role: "bg.app",
|
|
26
|
+
usage: "bg",
|
|
27
|
+
surface: "app",
|
|
28
|
+
context: "dark",
|
|
29
|
+
};
|
|
30
|
+
const explicitResolved = theme.resolve(explicit);
|
|
31
|
+
expect(bound.step).toBe(explicitResolved.step);
|
|
32
|
+
const override = darkTheme.resolve({
|
|
33
|
+
role: "bg.app",
|
|
34
|
+
usage: "bg",
|
|
35
|
+
surface: "app",
|
|
36
|
+
context: "light",
|
|
37
|
+
});
|
|
38
|
+
const light = {
|
|
39
|
+
role: "bg.app",
|
|
40
|
+
usage: "bg",
|
|
41
|
+
surface: "app",
|
|
42
|
+
context: "light",
|
|
43
|
+
};
|
|
44
|
+
const lightResolved = theme.resolve(light);
|
|
45
|
+
expect(override.step).toBe(lightResolved.step);
|
|
46
|
+
});
|
|
47
|
+
it("theme.color infers usage and surface from role", () => {
|
|
48
|
+
const inferred = theme.color("bg.app");
|
|
49
|
+
const explicit = theme.resolve({ role: "bg.app", usage: "bg", surface: "app" });
|
|
50
|
+
expect(inferred.step).toBe(explicit.step);
|
|
51
|
+
expect(inferred.seedUsed).toBe(explicit.seedUsed);
|
|
52
|
+
});
|
|
53
|
+
it("theme.color throws in strict mode when inference is missing", () => {
|
|
54
|
+
expect(() => theme.color("custom.role", {
|
|
55
|
+
output: { strict: true },
|
|
56
|
+
})).toThrow(/Usage is required for role/i);
|
|
57
|
+
expect(() => theme.color("text.custom", {
|
|
58
|
+
output: { strict: true },
|
|
59
|
+
})).toThrow(/Surface is required for role/i);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|