@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.
- package/CHANGELOG.md +59 -0
- package/README.md +80 -177
- package/dist/contrast/contrast.d.ts +16 -0
- package/dist/contrast/contrast.js +102 -0
- package/dist/core/intent-registry.d.ts +11 -0
- package/dist/core/intent-registry.js +70 -0
- package/dist/core/oklch.d.ts +16 -0
- package/dist/core/oklch.js +56 -0
- package/dist/create-palette-kit.d.ts +9 -0
- package/dist/create-palette-kit.js +67 -0
- package/dist/engine/context/context.d.ts +13 -0
- package/dist/engine/context/context.js +37 -0
- package/dist/engine/level/curves.d.ts +17 -0
- package/dist/engine/level/curves.js +49 -0
- package/dist/engine/level/level.d.ts +4 -0
- package/dist/engine/level/level.js +13 -0
- package/dist/engine/relation/relation.d.ts +105 -0
- package/dist/engine/relation/relation.js +137 -0
- package/dist/engine/resolve/resolve.d.ts +36 -0
- package/dist/engine/resolve/resolve.js +116 -0
- package/dist/engine/state/state.d.ts +46 -0
- package/dist/engine/state/state.js +68 -0
- package/dist/engine/usage/fill.d.ts +9 -0
- package/dist/engine/usage/fill.js +9 -0
- package/dist/engine/usage/lines.d.ts +9 -0
- package/dist/engine/usage/lines.js +9 -0
- package/dist/engine/usage/overlays.d.ts +9 -0
- package/dist/engine/usage/overlays.js +9 -0
- package/dist/engine/usage/strategy.d.ts +56 -0
- package/dist/engine/usage/strategy.js +30 -0
- package/dist/engine/usage/visualVocabulary.d.ts +9 -0
- package/dist/engine/usage/visualVocabulary.js +9 -0
- package/dist/export/serialize.d.ts +14 -0
- package/dist/export/serialize.js +89 -0
- package/dist/export/types.d.ts +37 -0
- package/dist/export/types.js +31 -0
- package/dist/index.d.ts +3 -22
- package/dist/index.js +2 -18
- package/dist/operators/convert.d.ts +32 -0
- package/dist/operators/convert.js +80 -0
- package/dist/presets/presets.d.ts +95 -0
- package/dist/presets/presets.js +308 -0
- package/dist/types/index.d.ts +111 -0
- package/dist/utils/errors/errors.d.ts +17 -0
- package/dist/utils/errors/errors.js +22 -0
- package/docs/API.md +167 -0
- package/docs/Alpha.md +14 -0
- package/docs/Architecture.md +56 -0
- package/docs/CLI.md +22 -0
- package/docs/Concepts.md +73 -0
- package/docs/Config.md +144 -0
- package/docs/Diagnostics.md +22 -0
- package/docs/Exporters.md +33 -0
- package/docs/FAQ.md +59 -0
- package/docs/Migration.md +61 -0
- package/docs/Overlays.md +33 -0
- package/docs/README.md +60 -0
- package/docs/Text.md +41 -0
- package/docs/Tokens.md +42 -0
- package/docs/Usage-JSON.md +39 -0
- package/docs/Usage-ReactNative.md +63 -0
- package/docs/Usage-Web.md +66 -0
- package/docs/Validation.md +97 -0
- package/docs/Why.md +37 -0
- package/docs/_api-surface.md +53 -0
- package/docs/snippets/serialize-oklch.md +9 -0
- package/docs/spec.md +98 -0
- package/package.json +74 -52
- package/dist/alpha/generateAlphaScale.d.ts +0 -5
- package/dist/alpha/generateAlphaScale.js +0 -34
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -150
- package/dist/contrast/apca.d.ts +0 -2
- package/dist/contrast/apca.js +0 -5
- 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 → 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
|
-
|
|
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
|
-
|
|
7
|
+
## Install
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```bash
|
|
9
|
+
```sh
|
|
10
10
|
npm install @clhaas/palette-kit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
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 {
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
28
|
+
const surface = palette.resolve({
|
|
29
|
+
usage: "fill",
|
|
30
|
+
intent: "neutral",
|
|
31
|
+
level: 2,
|
|
64
32
|
});
|
|
65
33
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
41
|
+
## Public Runtime API
|
|
113
42
|
|
|
114
|
-
|
|
115
|
-
npx palette-kit generate --out src/theme.ts
|
|
116
|
-
```
|
|
43
|
+
The package root exports:
|
|
117
44
|
|
|
118
|
-
|
|
45
|
+
- `createPaletteKit`
|
|
46
|
+
- `softResolverConfig`
|
|
47
|
+
- `neutralResolverConfig`
|
|
48
|
+
- `strongResolverConfig`
|
|
49
|
+
- `defaultResolverConfig`
|
|
119
50
|
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
```
|
|
53
|
+
There are no public subpath exports, CLI commands, token exporters, or codegen
|
|
54
|
+
APIs in v0.4.
|
|
127
55
|
|
|
128
|
-
|
|
56
|
+
## Resolver Rules
|
|
129
57
|
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
Supported outputs:
|
|
147
77
|
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
```
|
|
85
|
+
RGB-like outputs use clipped 8-bit channels. Output never changes semantic
|
|
86
|
+
resolution.
|
|
153
87
|
|
|
154
|
-
|
|
88
|
+
Output precedence is resolver-level `output`, then palette-level `output`, then
|
|
89
|
+
host-injected `systemDefaultOutput`, then the explicit `oklch` default.
|
|
155
90
|
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
Use the React Native exporter and `useColorScheme()`:
|
|
93
|
+
`createPaletteKit` accepts `preset` and explicit `resolverConfig` overrides.
|
|
165
94
|
|
|
166
95
|
```ts
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
110
|
+
## Documentation
|
|
210
111
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
##
|
|
118
|
+
## Module Format
|
|
216
119
|
|
|
217
|
-
|
|
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>>;
|