@clhaas/palette-kit 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -23,10 +23,11 @@ pnpm add @clhaas/palette-kit
23
23
  - 12-step scale (light/dark) from a seed.
24
24
  - Semantic tokens for UI (`radix-like-ui` preset).
25
25
  - Alpha scale for overlays.
26
- - Exporters for TS, JSON, and CSS vars.
26
+ - Exporters for TS, JSON, CSS vars, Tailwind, and React Native.
27
+ - Auto anchor selection per mode (light/dark), overridable via `anchorStep`.
27
28
  - Basic contrast and gamut diagnostics.
28
29
 
29
- ## Usage example (planned API)
30
+ ## Usage example
30
31
 
31
32
  ```ts
32
33
  import { createTheme } from "@clhaas/palette-kit";
@@ -40,7 +41,7 @@ const theme = createTheme({
40
41
  danger: { source: "seed", value: "#ef4444" },
41
42
  },
42
43
  tokens: { preset: "radix-like-ui" },
43
- output: { format: "css", cssVarPrefix: "pk" },
44
+ p3: true,
44
45
  });
45
46
  ```
46
47
 
@@ -1,3 +1,4 @@
1
+ import type { GenerateScaleOptions } from "./generateScale.js";
1
2
  import type { ColorHex, ColorSource, Theme } from "./types.js";
2
3
  export type TokenOverrides = {
3
4
  light?: Record<string, ColorHex>;
@@ -27,6 +28,7 @@ export type CreateThemeOptions = {
27
28
  textPrimary?: number;
28
29
  textSecondary?: number;
29
30
  };
31
+ scale?: Omit<GenerateScaleOptions, "source" | "mode" | "p3">;
30
32
  p3?: boolean;
31
33
  };
32
34
  export declare function createTheme(options: CreateThemeOptions): Theme;
@@ -6,22 +6,35 @@ import { generateScale } from "./generateScale.js";
6
6
  import { buildPresetTokens } from "./tokens/presetRadixLikeUi.js";
7
7
  export function createTheme(options) {
8
8
  const includeP3 = options.p3 ?? false;
9
+ const scaleOptions = options.scale ?? {};
9
10
  const scales = {
10
- neutral: generateScale({ source: options.neutral, p3: includeP3 }),
11
- accent: generateScale({ source: options.accent, p3: includeP3 }),
11
+ neutral: generateScale({ source: options.neutral, ...scaleOptions, p3: includeP3 }),
12
+ accent: generateScale({ source: options.accent, ...scaleOptions, p3: includeP3 }),
12
13
  };
13
14
  if (options.semantic?.success) {
14
- scales.success = generateScale({ source: options.semantic.success, p3: includeP3 });
15
+ scales.success = generateScale({
16
+ source: options.semantic.success,
17
+ ...scaleOptions,
18
+ p3: includeP3,
19
+ });
15
20
  }
16
21
  if (options.semantic?.warning) {
17
- scales.warning = generateScale({ source: options.semantic.warning, p3: includeP3 });
22
+ scales.warning = generateScale({
23
+ source: options.semantic.warning,
24
+ ...scaleOptions,
25
+ p3: includeP3,
26
+ });
18
27
  }
19
28
  if (options.semantic?.danger) {
20
- scales.danger = generateScale({ source: options.semantic.danger, p3: includeP3 });
29
+ scales.danger = generateScale({
30
+ source: options.semantic.danger,
31
+ ...scaleOptions,
32
+ p3: includeP3,
33
+ });
21
34
  }
22
35
  if (options.extras) {
23
36
  for (const [key, source] of Object.entries(options.extras)) {
24
- scales[key] = generateScale({ source, p3: includeP3 });
37
+ scales[key] = generateScale({ source, ...scaleOptions, p3: includeP3 });
25
38
  }
26
39
  }
27
40
  const preset = options.tokens?.preset ?? "radix-like-ui";
@@ -1,4 +1,7 @@
1
1
  export function analyzeScale(scale) {
2
- const outOfGamutCount = scale.meta?.outOfGamutCount ?? 0;
3
- return { outOfGamutCount };
2
+ return {
3
+ outOfGamutCount: scale.meta?.outOfGamutCount ?? 0,
4
+ outOfP3GamutCount: scale.meta?.outOfP3GamutCount ?? 0,
5
+ anchorSteps: scale.meta?.anchorSteps,
6
+ };
4
7
  }
@@ -1,9 +1,43 @@
1
1
  import { type CurveConfig } from "./engine/curves.js";
2
2
  import type { ColorSource, Scale, Step, TemplateId } from "./types.js";
3
- type GenerateScaleOptions = {
3
+ export type AnchorStepOption = Step | "auto" | {
4
+ light?: Step | "auto";
5
+ dark?: Step | "auto";
6
+ };
7
+ export type AutoAnchorModeOptions = {
8
+ candidateSteps?: Step[];
9
+ backgroundStep?: Step;
10
+ backgroundSteps?: Step[];
11
+ solidStep?: Step;
12
+ textStep?: Step;
13
+ targetContrast?: number;
14
+ minBackgroundL?: number;
15
+ maxBackgroundL?: number;
16
+ minTextL?: number;
17
+ maxTextL?: number;
18
+ };
19
+ export type AutoAnchorOptions = {
20
+ candidateSteps?: Step[];
21
+ light?: AutoAnchorModeOptions;
22
+ dark?: AutoAnchorModeOptions;
23
+ };
24
+ export type SeedNormalizeRange = {
25
+ minL?: number;
26
+ maxL?: number;
27
+ minC?: number;
28
+ maxC?: number;
29
+ };
30
+ export type SeedNormalizeOptions = {
31
+ enabled?: boolean;
32
+ light?: SeedNormalizeRange;
33
+ dark?: SeedNormalizeRange;
34
+ };
35
+ export type GenerateScaleOptions = {
4
36
  source: ColorSource;
5
37
  mode?: "light" | "dark" | "both";
6
- anchorStep?: Step;
38
+ anchorStep?: AnchorStepOption;
39
+ autoAnchor?: AutoAnchorOptions;
40
+ seedNormalize?: SeedNormalizeOptions;
7
41
  template?: "auto" | TemplateId;
8
42
  curves?: CurveConfig;
9
43
  gamut?: {
@@ -12,4 +46,3 @@ type GenerateScaleOptions = {
12
46
  p3?: boolean;
13
47
  };
14
48
  export declare function generateScale(options: GenerateScaleOptions): Scale;
15
- export {};
@@ -1,8 +1,45 @@
1
+ import { apcaContrast } from "./contrast/apca.js";
2
+ import { onSolidTextTokens } from "./contrast/onSolid.js";
1
3
  import { radixSeeds } from "./data/radixSeeds.js";
2
4
  import { resolveCurves } from "./engine/curves.js";
3
5
  import { compressToP3, compressToSrgb, hexToOklch, inP3Gamut, inSrgbGamut, oklchToHex, oklchToP3, } from "./engine/oklch.js";
4
6
  import { selectTemplateId, templates } from "./engine/templates.js";
5
7
  const steps = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
8
+ const defaultAutoAnchor = {
9
+ candidateSteps: [8, 9, 10],
10
+ light: {
11
+ backgroundStep: 1,
12
+ backgroundSteps: [1, 2, 3],
13
+ solidStep: 9,
14
+ textStep: 12,
15
+ targetContrast: 90,
16
+ minBackgroundL: 0.86,
17
+ maxBackgroundL: 0.98,
18
+ minTextL: 0.22,
19
+ maxTextL: 0.55,
20
+ },
21
+ dark: {
22
+ backgroundStep: 1,
23
+ backgroundSteps: [1, 2, 3],
24
+ solidStep: 9,
25
+ textStep: 12,
26
+ targetContrast: 75,
27
+ minBackgroundL: 0.1,
28
+ maxBackgroundL: 0.32,
29
+ minTextL: 0.75,
30
+ maxTextL: 0.98,
31
+ },
32
+ };
33
+ const defaultSeedNormalize = {
34
+ light: {
35
+ minL: 0.35,
36
+ maxL: 0.9,
37
+ },
38
+ dark: {
39
+ minL: 0.32,
40
+ maxL: 0.82,
41
+ },
42
+ };
6
43
  function getSeedHex(source) {
7
44
  if (source.source === "seed") {
8
45
  return source.value;
@@ -17,13 +54,122 @@ function normalizeHue(hue) {
17
54
  const normalized = ((hue % 360) + 360) % 360;
18
55
  return normalized;
19
56
  }
20
- function buildScaleForMode(seedHex, templateId, anchorStep, curves, gamutStrategy = "compress", mode = "light", includeP3 = false) {
21
- const seedOklch = hexToOklch(seedHex);
57
+ function clampValue(value, min, max) {
58
+ let current = value;
59
+ if (min !== undefined) {
60
+ current = Math.max(min, current);
61
+ }
62
+ if (max !== undefined) {
63
+ current = Math.min(max, current);
64
+ }
65
+ return current;
66
+ }
67
+ function normalizeSeed(seed, range) {
68
+ const l = clampValue(seed.l, range.minL, range.maxL);
69
+ const c = clampValue(seed.c, range.minC, range.maxC);
70
+ return {
71
+ l,
72
+ c: Math.max(0, c),
73
+ h: seed.h,
74
+ };
75
+ }
76
+ function resolveAnchorOption(option, mode) {
77
+ if (!option) {
78
+ return undefined;
79
+ }
80
+ if (typeof option === "object") {
81
+ return option[mode];
82
+ }
83
+ return option;
84
+ }
85
+ function resolveSeedNormalizeOptions(options, autoEnabled) {
86
+ const enabled = options?.enabled ?? autoEnabled;
87
+ return {
88
+ enabled,
89
+ light: { ...defaultSeedNormalize.light, ...options?.light },
90
+ dark: { ...defaultSeedNormalize.dark, ...options?.dark },
91
+ };
92
+ }
93
+ function resolveCandidateSteps(candidateSteps) {
94
+ const unique = Array.from(new Set(candidateSteps ?? defaultAutoAnchor.candidateSteps));
95
+ return unique.length > 0 ? unique : [9];
96
+ }
97
+ function resolveAutoAnchorModeOptions(base, overrides, candidateSteps) {
98
+ const resolvedCandidates = resolveCandidateSteps(overrides?.candidateSteps ?? candidateSteps);
99
+ const resolvedBackgroundStep = overrides?.backgroundStep ?? base.backgroundStep;
100
+ const resolvedBackgroundSteps = overrides?.backgroundSteps ?? base.backgroundSteps;
101
+ return {
102
+ candidateSteps: resolvedCandidates,
103
+ backgroundStep: resolvedBackgroundStep,
104
+ backgroundSteps: resolvedBackgroundSteps.length > 0 ? resolvedBackgroundSteps : [resolvedBackgroundStep],
105
+ solidStep: overrides?.solidStep ?? base.solidStep,
106
+ textStep: overrides?.textStep ?? base.textStep,
107
+ targetContrast: overrides?.targetContrast ?? base.targetContrast,
108
+ minBackgroundL: overrides?.minBackgroundL ?? base.minBackgroundL,
109
+ maxBackgroundL: overrides?.maxBackgroundL ?? base.maxBackgroundL,
110
+ minTextL: overrides?.minTextL ?? base.minTextL,
111
+ maxTextL: overrides?.maxTextL ?? base.maxTextL,
112
+ };
113
+ }
114
+ function resolveAutoAnchorOptions(options) {
115
+ const candidateSteps = resolveCandidateSteps(options?.candidateSteps);
116
+ return {
117
+ light: resolveAutoAnchorModeOptions(defaultAutoAnchor.light, options?.light, candidateSteps),
118
+ dark: resolveAutoAnchorModeOptions(defaultAutoAnchor.dark, options?.dark, candidateSteps),
119
+ };
120
+ }
121
+ function rangePenalty(value, min, max, weight = 100) {
122
+ if (value < min) {
123
+ return (min - value) * weight;
124
+ }
125
+ if (value > max) {
126
+ return (value - max) * weight;
127
+ }
128
+ return 0;
129
+ }
130
+ function scoreScale(scale, options) {
131
+ const solid = scale[options.solidStep];
132
+ const onSolid = onSolidTextTokens(solid);
133
+ const contrast = Math.abs(apcaContrast(onSolid.primary, solid));
134
+ const text = scale[options.textStep];
135
+ const textL = hexToOklch(text).l;
136
+ const backgroundLs = options.backgroundSteps.map((step) => hexToOklch(scale[step]).l);
137
+ const minBackgroundL = Math.min(...backgroundLs);
138
+ const maxBackgroundL = Math.max(...backgroundLs);
139
+ let score = 0;
140
+ const contrastDelta = options.targetContrast - contrast;
141
+ if (contrastDelta > 0) {
142
+ score += contrastDelta * 2.2;
143
+ }
144
+ score += rangePenalty(minBackgroundL, options.minBackgroundL, options.maxBackgroundL, 180);
145
+ score += rangePenalty(maxBackgroundL, options.minBackgroundL, options.maxBackgroundL, 180);
146
+ score += rangePenalty(textL, options.minTextL, options.maxTextL, 120);
147
+ return score;
148
+ }
149
+ function pickAutoAnchorStep(seed, templateId, curves, gamutStrategy, mode, options) {
150
+ let bestStep = 9;
151
+ let bestScore = Number.POSITIVE_INFINITY;
152
+ for (const candidate of options.candidateSteps) {
153
+ const result = buildScaleForMode(seed, templateId, candidate, curves, gamutStrategy, mode, false);
154
+ const score = scoreScale(result.scale, options) + Math.abs(candidate - 9) * 2;
155
+ if (score < bestScore) {
156
+ bestScore = score;
157
+ bestStep = candidate;
158
+ }
159
+ else if (score === bestScore) {
160
+ if (Math.abs(candidate - 9) < Math.abs(bestStep - 9)) {
161
+ bestStep = candidate;
162
+ }
163
+ }
164
+ }
165
+ return bestStep;
166
+ }
167
+ function buildScaleForMode(seed, templateId, anchorStep, curves, gamutStrategy = "compress", mode = "light", includeP3 = false) {
22
168
  const template = templates[mode][templateId];
23
169
  const anchor = template[anchorStep];
24
- const dL = seedOklch.l - anchor.l;
25
- const dC = seedOklch.c - anchor.c;
26
- const dH = seedOklch.h - anchor.h;
170
+ const dL = seed.l - anchor.l;
171
+ const dC = seed.c - anchor.c;
172
+ const dH = seed.h - anchor.h;
27
173
  const curveSet = resolveCurves(curves);
28
174
  const output = {};
29
175
  const p3Output = includeP3 ? {} : undefined;
@@ -61,14 +207,30 @@ function buildScaleForMode(seedHex, templateId, anchorStep, curves, gamutStrateg
61
207
  }
62
208
  export function generateScale(options) {
63
209
  const seedHex = getSeedHex(options.source);
64
- const anchorStep = options.anchorStep ?? 9;
210
+ const seedOklch = hexToOklch(seedHex);
65
211
  const mode = options.mode ?? "both";
66
212
  const templateId = options.template === "auto" || !options.template
67
- ? selectTemplateId(hexToOklch(seedHex))
213
+ ? selectTemplateId(seedOklch)
68
214
  : options.template;
69
215
  const gamutStrategy = options.gamut?.strategy ?? "compress";
70
- const lightResult = buildScaleForMode(seedHex, templateId, anchorStep, options.curves, gamutStrategy, "light", options.p3 ?? false);
71
- const darkResult = buildScaleForMode(seedHex, templateId, anchorStep, options.curves, gamutStrategy, "dark", options.p3 ?? false);
216
+ const anchorOption = options.anchorStep ?? "auto";
217
+ const anchorLightOption = resolveAnchorOption(anchorOption, "light");
218
+ const anchorDarkOption = resolveAnchorOption(anchorOption, "dark");
219
+ const autoAnchorOptions = resolveAutoAnchorOptions(options.autoAnchor);
220
+ const autoEnabled = anchorLightOption === "auto" || anchorDarkOption === "auto";
221
+ const seedNormalize = resolveSeedNormalizeOptions(options.seedNormalize, autoEnabled);
222
+ const lightSeed = seedNormalize.enabled
223
+ ? normalizeSeed(seedOklch, seedNormalize.light)
224
+ : seedOklch;
225
+ const darkSeed = seedNormalize.enabled ? normalizeSeed(seedOklch, seedNormalize.dark) : seedOklch;
226
+ const lightAnchorStep = anchorLightOption === "auto"
227
+ ? pickAutoAnchorStep(lightSeed, templateId, options.curves, gamutStrategy, "light", autoAnchorOptions.light)
228
+ : (anchorLightOption ?? 9);
229
+ const darkAnchorStep = anchorDarkOption === "auto"
230
+ ? pickAutoAnchorStep(darkSeed, templateId, options.curves, gamutStrategy, "dark", autoAnchorOptions.dark)
231
+ : (anchorDarkOption ?? 9);
232
+ const lightResult = buildScaleForMode(lightSeed, templateId, lightAnchorStep, options.curves, gamutStrategy, "light", options.p3 ?? false);
233
+ const darkResult = buildScaleForMode(darkSeed, templateId, darkAnchorStep, options.curves, gamutStrategy, "dark", options.p3 ?? false);
72
234
  const scale = {
73
235
  light: lightResult.scale,
74
236
  dark: darkResult.scale,
@@ -76,6 +238,10 @@ export function generateScale(options) {
76
238
  meta: {
77
239
  outOfGamutCount: lightResult.outOfGamutCount + darkResult.outOfGamutCount,
78
240
  outOfP3GamutCount: lightResult.outOfP3GamutCount + darkResult.outOfP3GamutCount,
241
+ anchorSteps: {
242
+ light: lightAnchorStep,
243
+ dark: darkAnchorStep,
244
+ },
79
245
  },
80
246
  };
81
247
  if (mode === "light") {
@@ -83,6 +249,12 @@ export function generateScale(options) {
83
249
  ...scale,
84
250
  dark: scale.light,
85
251
  p3: scale.p3 ? { light: scale.p3.light, dark: scale.p3.light } : undefined,
252
+ meta: scale.meta
253
+ ? {
254
+ ...scale.meta,
255
+ anchorSteps: { light: lightAnchorStep, dark: lightAnchorStep },
256
+ }
257
+ : undefined,
86
258
  };
87
259
  }
88
260
  if (mode === "dark") {
@@ -90,6 +262,12 @@ export function generateScale(options) {
90
262
  ...scale,
91
263
  light: scale.dark,
92
264
  p3: scale.p3 ? { light: scale.p3.dark, dark: scale.p3.dark } : undefined,
265
+ meta: scale.meta
266
+ ? {
267
+ ...scale.meta,
268
+ anchorSteps: { light: darkAnchorStep, dark: darkAnchorStep },
269
+ }
270
+ : undefined,
93
271
  };
94
272
  }
95
273
  return scale;
package/dist/index.d.ts CHANGED
@@ -12,5 +12,6 @@ export { toJson, toJsonWithMode } from "./exporters/toJson.js";
12
12
  export { toReactNative } from "./exporters/toReactNative.js";
13
13
  export { toTailwind } from "./exporters/toTailwind.js";
14
14
  export { toTs, toTsWithMode } from "./exporters/toTs.js";
15
+ export type { AnchorStepOption, AutoAnchorModeOptions, AutoAnchorOptions, GenerateScaleOptions, SeedNormalizeOptions, SeedNormalizeRange, } from "./generateScale.js";
15
16
  export { generateScale } from "./generateScale.js";
16
17
  export type { AlphaScale, ColorHex, ColorSource, OklchColor, RadixSeedName, Scale, ScaleColorMode, ScaleDiagnostics, Step, TemplateId, Theme, ThemeColorMode, ThemeDiagnostics, } from "./types.js";
package/dist/types.d.ts CHANGED
@@ -13,6 +13,10 @@ export type ColorSource = {
13
13
  export type ScaleDiagnostics = {
14
14
  outOfGamutCount: number;
15
15
  outOfP3GamutCount?: number;
16
+ anchorSteps?: {
17
+ light: Step;
18
+ dark: Step;
19
+ };
16
20
  };
17
21
  export type Scale = {
18
22
  light: Record<Step, ColorHex>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clhaas/palette-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Easy way to create the color palette of your app",
5
5
  "license": "MIT",
6
6
  "author": "Claus Haas",
@@ -24,7 +24,7 @@
24
24
  "test:watch": "vitest",
25
25
  "typecheck": "tsc -p tsconfig.json --noEmit",
26
26
  "typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
27
- "lint:biome": "biome check .",
27
+ "lint:biome": "biome check --unsafe --write",
28
28
  "lint:md": "markdownlint \"**/*.md\" --ignore node_modules",
29
29
  "lint": "npm run lint:biome && npm run lint:md && npm run typecheck && npm run typecheck:tests",
30
30
  "update": "npx npm-check-updates -i"