@clhaas/palette-kit 0.1.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +80 -177
  3. package/dist/contrast/contrast.d.ts +16 -0
  4. package/dist/contrast/contrast.js +102 -0
  5. package/dist/core/intent-registry.d.ts +11 -0
  6. package/dist/core/intent-registry.js +70 -0
  7. package/dist/core/oklch.d.ts +16 -0
  8. package/dist/core/oklch.js +56 -0
  9. package/dist/create-palette-kit.d.ts +9 -0
  10. package/dist/create-palette-kit.js +67 -0
  11. package/dist/engine/context/context.d.ts +13 -0
  12. package/dist/engine/context/context.js +37 -0
  13. package/dist/engine/level/curves.d.ts +17 -0
  14. package/dist/engine/level/curves.js +49 -0
  15. package/dist/engine/level/level.d.ts +4 -0
  16. package/dist/engine/level/level.js +13 -0
  17. package/dist/engine/relation/relation.d.ts +105 -0
  18. package/dist/engine/relation/relation.js +137 -0
  19. package/dist/engine/resolve/resolve.d.ts +36 -0
  20. package/dist/engine/resolve/resolve.js +116 -0
  21. package/dist/engine/state/state.d.ts +46 -0
  22. package/dist/engine/state/state.js +68 -0
  23. package/dist/engine/usage/fill.d.ts +9 -0
  24. package/dist/engine/usage/fill.js +9 -0
  25. package/dist/engine/usage/lines.d.ts +9 -0
  26. package/dist/engine/usage/lines.js +9 -0
  27. package/dist/engine/usage/overlays.d.ts +9 -0
  28. package/dist/engine/usage/overlays.js +9 -0
  29. package/dist/engine/usage/strategy.d.ts +56 -0
  30. package/dist/engine/usage/strategy.js +30 -0
  31. package/dist/engine/usage/visualVocabulary.d.ts +9 -0
  32. package/dist/engine/usage/visualVocabulary.js +9 -0
  33. package/dist/export/serialize.d.ts +14 -0
  34. package/dist/export/serialize.js +89 -0
  35. package/dist/export/types.d.ts +37 -0
  36. package/dist/export/types.js +31 -0
  37. package/dist/index.d.ts +3 -22
  38. package/dist/index.js +2 -18
  39. package/dist/operators/convert.d.ts +32 -0
  40. package/dist/operators/convert.js +80 -0
  41. package/dist/presets/presets.d.ts +95 -0
  42. package/dist/presets/presets.js +308 -0
  43. package/dist/types/index.d.ts +111 -0
  44. package/dist/utils/errors/errors.d.ts +17 -0
  45. package/dist/utils/errors/errors.js +22 -0
  46. package/docs/API.md +167 -0
  47. package/docs/Alpha.md +14 -0
  48. package/docs/Architecture.md +56 -0
  49. package/docs/CLI.md +22 -0
  50. package/docs/Concepts.md +73 -0
  51. package/docs/Config.md +144 -0
  52. package/docs/Diagnostics.md +22 -0
  53. package/docs/Exporters.md +33 -0
  54. package/docs/FAQ.md +59 -0
  55. package/docs/Migration.md +61 -0
  56. package/docs/Overlays.md +33 -0
  57. package/docs/README.md +60 -0
  58. package/docs/Text.md +41 -0
  59. package/docs/Tokens.md +42 -0
  60. package/docs/Usage-JSON.md +39 -0
  61. package/docs/Usage-ReactNative.md +63 -0
  62. package/docs/Usage-Web.md +66 -0
  63. package/docs/Validation.md +97 -0
  64. package/docs/Why.md +37 -0
  65. package/docs/_api-surface.md +53 -0
  66. package/docs/snippets/serialize-oklch.md +9 -0
  67. package/docs/spec.md +98 -0
  68. package/package.json +74 -52
  69. package/dist/alpha/generateAlphaScale.d.ts +0 -5
  70. package/dist/alpha/generateAlphaScale.js +0 -34
  71. package/dist/cli.d.ts +0 -2
  72. package/dist/cli.js +0 -150
  73. package/dist/contrast/apca.d.ts +0 -2
  74. package/dist/contrast/apca.js +0 -5
  75. package/dist/contrast/onSolid.d.ts +0 -6
  76. package/dist/contrast/onSolid.js +0 -28
  77. package/dist/contrast/solveText.d.ts +0 -2
  78. package/dist/contrast/solveText.js +0 -31
  79. package/dist/createTheme.d.ts +0 -38
  80. package/dist/createTheme.js +0 -148
  81. package/dist/data/radixSeeds.d.ts +0 -3
  82. package/dist/data/radixSeeds.js +0 -34
  83. package/dist/diagnostics/analyzeScale.d.ts +0 -2
  84. package/dist/diagnostics/analyzeScale.js +0 -7
  85. package/dist/diagnostics/analyzeTheme.d.ts +0 -2
  86. package/dist/diagnostics/analyzeTheme.js +0 -35
  87. package/dist/diagnostics/warnings.d.ts +0 -2
  88. package/dist/diagnostics/warnings.js +0 -20
  89. package/dist/engine/curves.d.ts +0 -9
  90. package/dist/engine/curves.js +0 -48
  91. package/dist/engine/oklch.d.ts +0 -8
  92. package/dist/engine/oklch.js +0 -40
  93. package/dist/engine/templates.d.ts +0 -14
  94. package/dist/engine/templates.js +0 -45
  95. package/dist/exporters/selectColorMode.d.ts +0 -2
  96. package/dist/exporters/selectColorMode.js +0 -19
  97. package/dist/exporters/toCssVars.d.ts +0 -13
  98. package/dist/exporters/toCssVars.js +0 -108
  99. package/dist/exporters/toJson.d.ts +0 -3
  100. package/dist/exporters/toJson.js +0 -25
  101. package/dist/exporters/toReactNative.d.ts +0 -54
  102. package/dist/exporters/toReactNative.js +0 -33
  103. package/dist/exporters/toTailwind.d.ts +0 -17
  104. package/dist/exporters/toTailwind.js +0 -111
  105. package/dist/exporters/toTs.d.ts +0 -3
  106. package/dist/exporters/toTs.js +0 -43
  107. package/dist/generateScale.d.ts +0 -48
  108. package/dist/generateScale.js +0 -274
  109. package/dist/overlays/generateOverlayScale.d.ts +0 -2
  110. package/dist/overlays/generateOverlayScale.js +0 -34
  111. package/dist/text/generateTextScale.d.ts +0 -8
  112. package/dist/text/generateTextScale.js +0 -18
  113. package/dist/text/resolveOnBgText.d.ts +0 -9
  114. package/dist/text/resolveOnBgText.js +0 -28
  115. package/dist/tokens/presetRadixLikeUi.d.ts +0 -5
  116. package/dist/tokens/presetRadixLikeUi.js +0 -55
  117. package/dist/types.d.ts +0 -69
  118. /package/dist/{types.js → types/index.js} +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ <!-- markdownlint-disable MD024 -->
4
+
5
+ ## v0.4.0
6
+
7
+ ### Breaking changes
8
+
9
+ - Replaces the previous experimental `createTheme` API with `createPaletteKit`.
10
+ - Publishes only the package root export. CLI commands, token exporters, and
11
+ serializer subpaths are not part of v0.4.
12
+ - Resolver options are modeled as explicit axes: `usage`, `intent`, `level`,
13
+ relation, `state`, `context`, and `output`.
14
+
15
+ ### Features
16
+
17
+ - Public `createPaletteKit` factory and `palette.resolve`.
18
+ - Public resolver presets: `soft`, `neutral`, and `strong`.
19
+ - Explicit `resolverConfig` overrides for level curves, state deltas, relation
20
+ parameters, and chroma limits.
21
+ - APCA contrast enforcement for `on` relations with a default Lc 60 target.
22
+ - Functional `over` and `under` overlay relations with configured alpha and
23
+ depth behavior.
24
+ - Supported outputs: `oklch`, `oklab`, `srgb`, `p3`, `hex`, and `rgba`.
25
+ - Strict public TypeScript option types for invalid usage/level/relation/state
26
+ combinations where possible.
27
+
28
+ ## v0.3.0
29
+
30
+ ### Breaking changes
31
+
32
+ - ESM-only package (`"type": "module"`), no `require()` support.
33
+ - Public API is split into subpath exports:
34
+ - `@clhaas/palette-kit` (runtime)
35
+ - `@clhaas/palette-kit/serialize` (serializer)
36
+ - `@clhaas/palette-kit/export` (exporters)
37
+ - `@clhaas/palette-kit/cli` and bin `palette-kit` (CLI)
38
+ - Exporters are not re-exported from the main entrypoint to keep the runtime lean and tree-shakeable.
39
+
40
+ ### Features
41
+
42
+ - Public serializer (`serializeColor`, `serializeResolved`, `theme.serialize`) with OKLCH/sRGB/P3 output options.
43
+ - Public exporters: `exportThemeCss` (progressive `@supports` fallbacks) and `exportThemeJson` (stable `{ light, dark }` structure).
44
+ - Declarative Token Registry + official token presets (`minimal-ui`, `radixLike-ui`, `modern-ui`).
45
+ - CLI tooling:
46
+ - `palette-kit init` (typed config template)
47
+ - `palette-kit build` (deterministic `dist/palette/` artifacts: CSS/JSON/TS + d.ts)
48
+ - Strong inference and DX validation improvements (strict vs non-strict behavior, clearer errors).
49
+
50
+ ### Migration
51
+
52
+ - See `docs/Migration.md` for upgrade notes and updated import paths.
53
+
54
+ ## v0.2.0
55
+
56
+ - Public API limited to `createTheme` and public types.
57
+ - Resolver returns OKLCH channel data, not CSS strings.
58
+ - Internal serializers/exporters exist but are not exported.
59
+ - CLI is declared but not implemented in this tag.
package/README.md CHANGED
@@ -1,217 +1,120 @@
1
1
  # Palette Kit
2
2
 
3
- Modern palette generator (OKLCH + APCA) with Radix-like steps. The library accepts seeds (initial colors) and produces light/dark scales, semantic tokens, and exporters ready for use.
3
+ Palette Kit v0.4 is a deterministic OKLCH color resolution engine. It resolves
4
+ semantic color requests from explicit axes and serializes the result only after
5
+ resolution.
4
6
 
5
- Status: WIP. The API may change while the MVP is under construction.
7
+ ## Install
6
8
 
7
- ## Installation
8
-
9
- ```bash
9
+ ```sh
10
10
  npm install @clhaas/palette-kit
11
11
  ```
12
12
 
13
- ```bash
14
- yarn add @clhaas/palette-kit
15
- ```
16
-
17
- ```bash
18
- pnpm add @clhaas/palette-kit
19
- ```
20
-
21
- ## What the library provides
22
-
23
- - 12-step scale (light/dark) from a seed.
24
- - Semantic tokens for UI (`radix-like-ui` preset).
25
- - Alpha scales per palette slot (chromatic alpha).
26
- - Overlay scales (black/white alpha).
27
- - Deterministic text scales for light/dark backgrounds.
28
- - Exporters for TS, JSON, CSS vars, Tailwind, and React Native.
29
- - Auto anchor selection per mode (light/dark), overridable via `anchorStep`.
30
- - Basic contrast and gamut diagnostics.
31
-
32
- ## Usage example
13
+ ## Quick Start
33
14
 
34
15
  ```ts
35
- import { createTheme } from "@clhaas/palette-kit";
36
-
37
- const theme = createTheme({
38
- neutral: { source: "seed", value: "#111827" },
39
- accent: { source: "seed", value: "#3d63dd" },
40
- semantic: {
41
- success: { source: "seed", value: "#16a34a" },
42
- warning: { source: "seed", value: "#f59e0b" },
43
- danger: { source: "seed", value: "#ef4444" },
44
- },
45
- tokens: { preset: "radix-like-ui" },
46
- text: {
47
- darkBase: "#1C1C1E",
48
- lightBase: "#F5F5F7",
16
+ import { createPaletteKit } from "@clhaas/palette-kit";
17
+
18
+ const palette = createPaletteKit({
19
+ context: "light",
20
+ output: "oklch",
21
+ preset: "neutral",
22
+ intents: {
23
+ brand: { hue: 260, chroma: 0.14 },
24
+ neutral: { hue: 0, chroma: 0 },
49
25
  },
50
- p3: true,
51
26
  });
52
- ```
53
-
54
- ## Quick start
55
-
56
- Generate a theme and export CSS variables:
57
-
58
- ```ts
59
- import { createTheme, toCssVars } from "@clhaas/palette-kit";
60
27
 
61
- const theme = createTheme({
62
- neutral: { source: "seed", value: "#111827" },
63
- accent: { source: "seed", value: "#3d63dd" },
28
+ const surface = palette.resolve({
29
+ usage: "fill",
30
+ intent: "neutral",
31
+ level: 2,
64
32
  });
65
33
 
66
- const css = toCssVars(theme, { prefix: "pk" });
67
- ```
68
-
69
- Use tokens in your app:
70
-
71
- ```css
72
- :root {
73
- /* paste the generated CSS vars here */
74
- }
75
-
76
- body {
77
- background: var(--pk-bg-app);
78
- color: var(--pk-text-primary);
79
- }
80
- ```
81
-
82
- ## Step-by-step (install -> usage -> types)
83
-
84
- 1) Install:
85
-
86
- ```bash
87
- npm install @clhaas/palette-kit
88
- ```
89
-
90
- 2) Create a config file (`palette.config.mjs`):
91
-
92
- ```js
93
- /** @type {import("@clhaas/palette-kit").CreateThemeOptions} */
94
- export default {
95
- neutral: { source: "seed", value: "#111827" },
96
- accent: { source: "seed", value: "#3d63dd" },
97
- semantic: {
98
- success: { source: "seed", value: "#16a34a" },
99
- warning: { source: "seed", value: "#f59e0b" },
100
- danger: { source: "seed", value: "#ef4444" },
101
- },
102
- tokens: { preset: "radix-like-ui" },
103
- alpha: { enabled: true },
104
- text: {
105
- darkBase: "#1C1C1E",
106
- lightBase: "#F5F5F7",
107
- },
108
- p3: true,
109
- };
34
+ const text = palette.resolve({
35
+ usage: "visualVocabulary",
36
+ intent: "brand",
37
+ on: surface,
38
+ });
110
39
  ```
111
40
 
112
- 3) Generate a typed theme file (includes token name types):
41
+ ## Public Runtime API
113
42
 
114
- ```bash
115
- npx palette-kit generate --out src/theme.ts
116
- ```
43
+ The package root exports:
117
44
 
118
- 4) Export CSS vars (for web apps):
45
+ - `createPaletteKit`
46
+ - `softResolverConfig`
47
+ - `neutralResolverConfig`
48
+ - `strongResolverConfig`
49
+ - `defaultResolverConfig`
119
50
 
120
- ```ts
121
- import { toCssVars } from "@clhaas/palette-kit";
122
- import { theme } from "./theme";
51
+ Public TypeScript types are also exported from the package root.
123
52
 
124
- const css = toCssVars(theme, { prefix: "pk" });
125
- // write the string into a .css file or inject it at build time
126
- ```
53
+ There are no public subpath exports, CLI commands, token exporters, or codegen
54
+ APIs in v0.4.
127
55
 
128
- 5) Use the generated types:
56
+ ## Resolver Rules
129
57
 
130
- ```ts
131
- import { theme, ThemeTokenMap, ThemeTokenName } from "./theme";
58
+ | Usage | Level | Relations |
59
+ | --- | --- | --- |
60
+ | `fill` | Required | `on` optional |
61
+ | `visualVocabulary` | Forbidden | `on` required |
62
+ | `lines` | Required | `on` optional |
63
+ | `overlays` | Required | `over` or `under` optional |
132
64
 
133
- const tokens: ThemeTokenMap = theme.tokens.light;
134
- const tokenName: ThemeTokenName = "bg.app";
135
- ```
65
+ `state` defaults to `"default"`. Non-default states require explicit
66
+ `stateDirection`; Palette Kit never infers whether state should increase or
67
+ decrease lightness.
136
68
 
137
- ## Alpha, overlay, and text examples
69
+ Context is explicit. Provide palette-level `context`, resolver-level `context`,
70
+ or host-injected `systemDefaultContext`. Context affects default level curves;
71
+ for example, dark context inverts the structural lightness scale while
72
+ preserving intent hue and chroma.
138
73
 
139
- ```ts
140
- const accentAlpha = theme.alpha?.accent.light[5];
141
- const overlay = theme.overlay.black[9];
142
- const textOnLight = theme.tokens.light["text.dark.primary"];
143
- const textOnDark = theme.tokens.dark["text.light.primary"];
144
- ```
74
+ ## Output
145
75
 
146
- ## Migration note (alpha)
76
+ Supported outputs:
147
77
 
148
- Alpha scales are now generated per palette slot. If you previously used:
78
+ - `oklch`: normalized OKLCH object
79
+ - `oklab`: OKLab object
80
+ - `srgb`: `{ r, g, b, alpha }`
81
+ - `p3`: Display-P3 `{ r, g, b, alpha }`
82
+ - `hex`: `#rrggbb`
83
+ - `rgba`: `{ r, g, b, a }`
149
84
 
150
- ```ts
151
- theme.alpha?.light[5];
152
- ```
85
+ RGB-like outputs use clipped 8-bit channels. Output never changes semantic
86
+ resolution.
153
87
 
154
- Update to:
88
+ Output precedence is resolver-level `output`, then palette-level `output`, then
89
+ host-injected `systemDefaultOutput`, then the explicit `oklch` default.
155
90
 
156
- ```ts
157
- theme.alpha?.accent.light[5];
158
- ```
159
-
160
- CSS variables were also updated from `--pk-alpha-<step>` to `--pk-alpha-<slot>-<step>`.
91
+ ## Configuration
161
92
 
162
- ## React Native + Expo
163
-
164
- Use the React Native exporter and `useColorScheme()`:
93
+ `createPaletteKit` accepts `preset` and explicit `resolverConfig` overrides.
165
94
 
166
95
  ```ts
167
- import { useMemo } from "react";
168
- import { useColorScheme } from "react-native";
169
- import { createTheme, toReactNative } from "@clhaas/palette-kit";
170
-
171
- const theme = createTheme({
172
- neutral: { source: "seed", value: "#111827" },
173
- accent: { source: "seed", value: "#3d63dd" },
174
- p3: true,
96
+ const palette = createPaletteKit({
97
+ context: "light",
98
+ preset: "soft",
99
+ intents,
100
+ resolverConfig: {
101
+ relationParams: {
102
+ on: { contrastTarget: 75 },
103
+ },
104
+ },
175
105
  });
176
-
177
- export function usePalette() {
178
- const scheme = useColorScheme();
179
- const palette = useMemo(() => toReactNative(theme, { includeP3: true }), []);
180
- return scheme === "dark" ? palette.dark : palette.light;
181
- }
182
106
  ```
183
107
 
184
- See `examples/expo` for a full example.
185
-
186
- Note: React Native does not support `color(display-p3 ...)` strings as drop-in colors. The `p3` field is provided as data for platforms that can handle wide color via native APIs.
187
-
188
- ## Principles
189
-
190
- - Tokens by intent, not by color.
191
- - Fixed steps (1-12) for UI consistency.
192
- - OKLCH generation, contrast resolved with APCA.
193
-
194
- ## Docs and plans
195
-
196
- - `docs/README.md`
197
- - `docs/concepts.md`
198
- - `docs/api.md`
199
- - `docs/tokens.md`
200
- - `docs/contrast.md`
201
- - `docs/alpha.md`
202
- - `docs/text.md`
203
- - `docs/overlays.md`
204
- - `docs/Why.md`
205
- - `docs/spec-implementation.md`
206
- - `docs/plan-tests.md`
207
- - `docs/plan-docs.md`
108
+ The default preset is `neutral`.
208
109
 
209
- ## Short roadmap
110
+ ## Documentation
210
111
 
211
- 1) Generate scales from seeds (light/dark).
212
- 2) Tokens and basic exporters.
213
- 3) Contrast and alpha scale.
112
+ - [Docs index](docs/README.md)
113
+ - [API](docs/API.md)
114
+ - [Configuration](docs/Config.md)
115
+ - [Specification summary](docs/spec.md)
116
+ - [Migration](docs/Migration.md)
214
117
 
215
- ## License
118
+ ## Module Format
216
119
 
217
- MIT
120
+ Palette Kit is ESM-only.
@@ -0,0 +1,16 @@
1
+ import { type OklchColor } from '../core/oklch.js';
2
+ import type { Context } from '../engine/context/context.js';
3
+ import type { ChromaConfig, RelationParamsConfig } from '../presets/presets.js';
4
+ export type ContrastResolutionConfig = Readonly<{
5
+ on: RelationParamsConfig['on'];
6
+ chromaLimits: ChromaConfig;
7
+ }>;
8
+ export type ContrastResolutionInput = Readonly<{
9
+ color: OklchColor;
10
+ target: OklchColor;
11
+ context: Context;
12
+ config: ContrastResolutionConfig;
13
+ }>;
14
+ export declare function measureApcaContrast(foreground: OklchColor, background: OklchColor): number;
15
+ export declare function measureWcagContrast(foreground: OklchColor, background: OklchColor): number;
16
+ export declare function resolveOnContrast({ color, config, context, target, }: ContrastResolutionInput): OklchColor;
@@ -0,0 +1,102 @@
1
+ import { APCAcontrast, sRGBtoY } from 'apca-w3';
2
+ import { normalizeOklch } from '../core/oklch.js';
3
+ import { serializeOklchToSrgb } from '../export/serialize.js';
4
+ import { createContrastUnsatisfiableError } from '../utils/errors/errors.js';
5
+ const LIGHTNESS_STEP = 0.5;
6
+ const CONTRAST_PRECISION = 2;
7
+ const clampLightness = (value) => Math.min(100, Math.max(0, value));
8
+ const roundContrast = (contrast) => Number(contrast.toFixed(CONTRAST_PRECISION));
9
+ export function measureApcaContrast(foreground, background) {
10
+ const foregroundRgb = serializeOklchToSrgb(foreground);
11
+ const backgroundRgb = serializeOklchToSrgb(background);
12
+ const contrast = APCAcontrast(sRGBtoY([foregroundRgb.r, foregroundRgb.g, foregroundRgb.b]), sRGBtoY([backgroundRgb.r, backgroundRgb.g, backgroundRgb.b]));
13
+ const numericContrast = typeof contrast === 'number' ? contrast : Number(contrast);
14
+ if (Number.isFinite(numericContrast)) {
15
+ return numericContrast;
16
+ }
17
+ const wcagContrast = measureWcagContrast(foreground, background);
18
+ const polarity = foreground.l <= background.l ? 1 : -1;
19
+ return polarity * wcagContrast * 10;
20
+ }
21
+ const channelToLinear = (channel) => {
22
+ const normalized = channel / 255;
23
+ return normalized <= 0.03928
24
+ ? normalized / 12.92
25
+ : ((normalized + 0.055) / 1.055) ** 2.4;
26
+ };
27
+ const relativeLuminance = (color) => {
28
+ const rgb = serializeOklchToSrgb(color);
29
+ return (0.2126 * channelToLinear(rgb.r) +
30
+ 0.7152 * channelToLinear(rgb.g) +
31
+ 0.0722 * channelToLinear(rgb.b));
32
+ };
33
+ export function measureWcagContrast(foreground, background) {
34
+ const foregroundLuminance = relativeLuminance(foreground);
35
+ const backgroundLuminance = relativeLuminance(background);
36
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
37
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
38
+ return (lighter + 0.05) / (darker + 0.05);
39
+ }
40
+ const hasTargetContrast = (contrast, target) => Math.abs(contrast) >= target;
41
+ const createCandidate = (color, lightness, chroma) => normalizeOklch({
42
+ alpha: color.alpha,
43
+ c: Math.max(0, chroma),
44
+ h: color.h,
45
+ l: clampLightness(lightness),
46
+ });
47
+ const selectContrastDirections = (target, context) => {
48
+ if (target.l < 45) {
49
+ return ['increase', 'decrease'];
50
+ }
51
+ if (target.l > 55) {
52
+ return ['decrease', 'increase'];
53
+ }
54
+ return context === 'dark'
55
+ ? ['increase', 'decrease']
56
+ : ['decrease', 'increase'];
57
+ };
58
+ const scanLightness = (color, target, chroma, direction, maxLuminanceShift, contrastTarget) => {
59
+ const signedStep = direction === 'increase' ? LIGHTNESS_STEP : -LIGHTNESS_STEP;
60
+ const iterations = Math.ceil(maxLuminanceShift / LIGHTNESS_STEP);
61
+ let bestColor = createCandidate(color, color.l, chroma);
62
+ let bestContrast = measureApcaContrast(bestColor, target);
63
+ for (let index = 0; index <= iterations; index += 1) {
64
+ const lightness = color.l + signedStep * index;
65
+ const shift = Math.abs(lightness - color.l);
66
+ if (shift > maxLuminanceShift) {
67
+ break;
68
+ }
69
+ const candidate = createCandidate(color, lightness, chroma);
70
+ const contrast = measureApcaContrast(candidate, target);
71
+ if (Math.abs(contrast) > Math.abs(bestContrast)) {
72
+ bestColor = candidate;
73
+ bestContrast = contrast;
74
+ }
75
+ if (hasTargetContrast(contrast, contrastTarget)) {
76
+ break;
77
+ }
78
+ }
79
+ return { bestColor, bestContrast };
80
+ };
81
+ export function resolveOnContrast({ color, config, context, target, }) {
82
+ const contrastTarget = config.on.contrastTarget;
83
+ const maxReduction = Math.min(color.c, color.c * config.chromaLimits.maxReduction);
84
+ const chromaStep = config.chromaLimits.reductionStep;
85
+ let bestContrast = measureApcaContrast(color, target);
86
+ if (hasTargetContrast(bestContrast, contrastTarget)) {
87
+ return Object.freeze(color);
88
+ }
89
+ for (let reduction = 0; reduction <= maxReduction + chromaStep / 2; reduction += chromaStep) {
90
+ const chroma = Math.max(0, color.c - reduction);
91
+ for (const direction of selectContrastDirections(target, context)) {
92
+ const result = scanLightness(color, target, chroma, direction, config.on.maxLuminanceShift, contrastTarget);
93
+ if (Math.abs(result.bestContrast) > Math.abs(bestContrast)) {
94
+ bestContrast = result.bestContrast;
95
+ }
96
+ if (hasTargetContrast(result.bestContrast, contrastTarget)) {
97
+ return Object.freeze(result.bestColor);
98
+ }
99
+ }
100
+ }
101
+ throw createContrastUnsatisfiableError(roundContrast(Math.abs(bestContrast)), contrastTarget);
102
+ }
@@ -0,0 +1,11 @@
1
+ export type IntentName = string;
2
+ export type IntentDefinition = {
3
+ hue: number;
4
+ chroma: number;
5
+ };
6
+ export type IntentRegistry<I extends string = string> = Readonly<{
7
+ intents: Readonly<Record<I, Readonly<IntentDefinition>>>;
8
+ }>;
9
+ export declare function createIntentRegistry<const I extends string>(intents: Record<I, IntentDefinition>): IntentRegistry<I>;
10
+ export declare function hasIntent<I extends string>(registry: IntentRegistry<I>, intent: IntentName): intent is I;
11
+ export declare function getIntent<I extends string>(registry: IntentRegistry<I>, intent: IntentName): Readonly<IntentDefinition>;
@@ -0,0 +1,70 @@
1
+ import { createUnknownIntentError } from '../utils/errors/errors.js';
2
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
3
+ const normalizeHue = (hue) => {
4
+ const normalized = ((hue % 360) + 360) % 360;
5
+ return Object.is(normalized, -0) ? 0 : normalized;
6
+ };
7
+ const forbiddenIntentTokens = Object.freeze({
8
+ level: new Set(['strong', 'subtle', 'weak', 'muted', 'heavy']),
9
+ relation: new Set(['on', 'over', 'under', 'overlay']),
10
+ state: new Set(['hover', 'active', 'focus', 'selected', 'disabled']),
11
+ usage: new Set(['text', 'border', 'icon', 'fill', 'line', 'lines']),
12
+ visual: new Set(['green', 'red', 'blue', 'dark', 'light']),
13
+ });
14
+ const splitIntentName = (name) => name
15
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
16
+ .split(/[-_]+|\s+/)
17
+ .map((part) => part.toLowerCase())
18
+ .filter((part) => part.length > 0);
19
+ const validateIntentName = (name) => {
20
+ if (name.length === 0) {
21
+ throw new Error('Intent name must not be empty.');
22
+ }
23
+ if (/\s/.test(name)) {
24
+ throw new Error(`Intent name "${name}" must not contain whitespace.`);
25
+ }
26
+ if (name.includes('.')) {
27
+ throw new Error(`Intent name "${name}" must use a flat namespace.`);
28
+ }
29
+ const tokens = splitIntentName(name);
30
+ for (const [category, forbiddenTokens] of Object.entries(forbiddenIntentTokens)) {
31
+ if (tokens.some((token) => forbiddenTokens.has(token))) {
32
+ throw new Error(`Intent name "${name}" must describe meaning only and must not encode ${category}.`);
33
+ }
34
+ }
35
+ };
36
+ const normalizeIntentDefinition = (name, definition) => {
37
+ if (!isFiniteNumber(definition.hue)) {
38
+ throw new Error(`Intent "${name}" hue must be a finite number.`);
39
+ }
40
+ if (!isFiniteNumber(definition.chroma)) {
41
+ throw new Error(`Intent "${name}" chroma must be a finite number.`);
42
+ }
43
+ if (definition.chroma < 0) {
44
+ throw new Error(`Intent "${name}" chroma must be greater than or equal to 0.`);
45
+ }
46
+ return Object.freeze({
47
+ chroma: definition.chroma,
48
+ hue: normalizeHue(definition.hue),
49
+ });
50
+ };
51
+ export function createIntentRegistry(intents) {
52
+ const entries = Object.entries(intents);
53
+ const normalized = {};
54
+ for (const [name, definition] of entries) {
55
+ validateIntentName(name);
56
+ normalized[name] = normalizeIntentDefinition(name, definition);
57
+ }
58
+ return Object.freeze({
59
+ intents: Object.freeze(normalized),
60
+ });
61
+ }
62
+ export function hasIntent(registry, intent) {
63
+ return Object.hasOwn(registry.intents, intent);
64
+ }
65
+ export function getIntent(registry, intent) {
66
+ if (!hasIntent(registry, intent)) {
67
+ throw createUnknownIntentError(intent);
68
+ }
69
+ return registry.intents[intent];
70
+ }
@@ -0,0 +1,16 @@
1
+ export type OklchColor = {
2
+ space: 'oklch';
3
+ l: number;
4
+ c: number;
5
+ h: number;
6
+ alpha: number;
7
+ };
8
+ export type OklchInput = {
9
+ l: number;
10
+ c: number;
11
+ h: number;
12
+ alpha?: number;
13
+ };
14
+ export declare function normalizeOklch(input: OklchInput): OklchColor;
15
+ export declare function isOklchColor(value: unknown): value is OklchColor;
16
+ export declare function assertOklchColor(value: unknown): asserts value is OklchColor;
@@ -0,0 +1,56 @@
1
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
2
+ const normalizeHue = (hue) => {
3
+ const normalized = ((hue % 360) + 360) % 360;
4
+ return Object.is(normalized, -0) ? 0 : normalized;
5
+ };
6
+ const validateFiniteChannel = (name, value) => {
7
+ if (!isFiniteNumber(value)) {
8
+ throw new Error(`OKLCH ${name} must be a finite number.`);
9
+ }
10
+ };
11
+ export function normalizeOklch(input) {
12
+ validateFiniteChannel('l', input.l);
13
+ validateFiniteChannel('c', input.c);
14
+ validateFiniteChannel('h', input.h);
15
+ if (input.l < 0 || input.l > 100) {
16
+ throw new Error('OKLCH l must be between 0 and 100.');
17
+ }
18
+ if (input.c < 0) {
19
+ throw new Error('OKLCH c must be greater than or equal to 0.');
20
+ }
21
+ const alpha = input.alpha ?? 1;
22
+ validateFiniteChannel('alpha', alpha);
23
+ if (alpha < 0 || alpha > 1) {
24
+ throw new Error('OKLCH alpha must be between 0 and 1.');
25
+ }
26
+ return {
27
+ alpha,
28
+ c: input.c,
29
+ h: normalizeHue(input.h),
30
+ l: input.l,
31
+ space: 'oklch',
32
+ };
33
+ }
34
+ export function isOklchColor(value) {
35
+ if (typeof value !== 'object' || value === null) {
36
+ return false;
37
+ }
38
+ const candidate = value;
39
+ return (candidate.space === 'oklch' &&
40
+ isFiniteNumber(candidate.l) &&
41
+ candidate.l >= 0 &&
42
+ candidate.l <= 100 &&
43
+ isFiniteNumber(candidate.c) &&
44
+ candidate.c >= 0 &&
45
+ isFiniteNumber(candidate.h) &&
46
+ candidate.h >= 0 &&
47
+ candidate.h < 360 &&
48
+ isFiniteNumber(candidate.alpha) &&
49
+ candidate.alpha >= 0 &&
50
+ candidate.alpha <= 1);
51
+ }
52
+ export function assertOklchColor(value) {
53
+ if (!isOklchColor(value)) {
54
+ throw new Error('Expected a normalized OKLCH color.');
55
+ }
56
+ }
@@ -0,0 +1,9 @@
1
+ import { type ColorOutput } from './export/types.js';
2
+ import type { PaletteDefaultOutput, PaletteKit, PaletteKitConfig } from './types/index.js';
3
+ /**
4
+ * Creates an immutable Palette Kit resolver instance.
5
+ *
6
+ * The factory normalizes the provided intent registry once, keeps context and
7
+ * output defaults explicit, and never reads ambient platform state.
8
+ */
9
+ export declare function createPaletteKit<const I extends string, const PaletteOutput extends ColorOutput | undefined = undefined, const SystemDefaultOutput extends ColorOutput | undefined = undefined>(config: PaletteKitConfig<I, PaletteOutput, SystemDefaultOutput>): PaletteKit<I, PaletteDefaultOutput<PaletteOutput, SystemDefaultOutput>>;