@clhaas/palette-kit 0.1.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/.markdownlint.json +4 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/biome.json +43 -0
- package/package.json +33 -0
- package/src/alpha/generateAlphaScale.ts +43 -0
- package/src/contrast/apca.ts +7 -0
- package/src/contrast/onSolid.ts +38 -0
- package/src/contrast/solveText.ts +49 -0
- package/src/createTheme.ts +130 -0
- package/src/data/radixSeeds.ts +37 -0
- package/src/diagnostics/analyzeScale.ts +6 -0
- package/src/diagnostics/analyzeTheme.ts +54 -0
- package/src/diagnostics/warnings.ts +25 -0
- package/src/engine/curves.ts +64 -0
- package/src/engine/oklch.ts +53 -0
- package/src/engine/templates.ts +58 -0
- package/src/exporters/selectColorMode.ts +25 -0
- package/src/exporters/toCssVars.ts +116 -0
- package/src/exporters/toJson.ts +31 -0
- package/src/exporters/toReactNative.ts +39 -0
- package/src/exporters/toTailwind.ts +110 -0
- package/src/exporters/toTs.ts +34 -0
- package/src/generateScale.ts +163 -0
- package/src/index.ts +30 -0
- package/src/tokens/presetRadixLikeUi.ts +75 -0
- package/src/types.ts +63 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Claus Haas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Palette Kit
|
|
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.
|
|
4
|
+
|
|
5
|
+
Status: WIP. The API may change while the MVP is under construction.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @clhaas/palette-kit
|
|
11
|
+
```
|
|
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 scale for overlays.
|
|
26
|
+
- Exporters for TS, JSON, and CSS vars.
|
|
27
|
+
- Basic contrast and gamut diagnostics.
|
|
28
|
+
|
|
29
|
+
## Usage example (planned API)
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createTheme } from "@clhaas/palette-kit";
|
|
33
|
+
|
|
34
|
+
const theme = createTheme({
|
|
35
|
+
neutral: { source: "seed", value: "#111827" },
|
|
36
|
+
accent: { source: "seed", value: "#3d63dd" },
|
|
37
|
+
semantic: {
|
|
38
|
+
success: { source: "seed", value: "#16a34a" },
|
|
39
|
+
warning: { source: "seed", value: "#f59e0b" },
|
|
40
|
+
danger: { source: "seed", value: "#ef4444" },
|
|
41
|
+
},
|
|
42
|
+
tokens: { preset: "radix-like-ui" },
|
|
43
|
+
output: { format: "css", cssVarPrefix: "pk" },
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
Generate a theme and export CSS variables:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createTheme, toCssVars } from "@clhaas/palette-kit";
|
|
53
|
+
|
|
54
|
+
const theme = createTheme({
|
|
55
|
+
neutral: { source: "seed", value: "#111827" },
|
|
56
|
+
accent: { source: "seed", value: "#3d63dd" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const css = toCssVars(theme, { prefix: "pk" });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use tokens in your app:
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
:root {
|
|
66
|
+
/* paste the generated CSS vars here */
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
body {
|
|
70
|
+
background: var(--pk-bg-app);
|
|
71
|
+
color: var(--pk-text-primary);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## React Native + Expo
|
|
76
|
+
|
|
77
|
+
Use the React Native exporter and `useColorScheme()`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { useMemo } from "react";
|
|
81
|
+
import { useColorScheme } from "react-native";
|
|
82
|
+
import { createTheme, toReactNative } from "@clhaas/palette-kit";
|
|
83
|
+
|
|
84
|
+
const theme = createTheme({
|
|
85
|
+
neutral: { source: "seed", value: "#111827" },
|
|
86
|
+
accent: { source: "seed", value: "#3d63dd" },
|
|
87
|
+
p3: true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function usePalette() {
|
|
91
|
+
const scheme = useColorScheme();
|
|
92
|
+
const palette = useMemo(() => toReactNative(theme, { includeP3: true }), []);
|
|
93
|
+
return scheme === "dark" ? palette.dark : palette.light;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
See `examples/expo` for a full example.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
## Principles
|
|
102
|
+
|
|
103
|
+
- Tokens by intent, not by color.
|
|
104
|
+
- Fixed steps (1-12) for UI consistency.
|
|
105
|
+
- OKLCH generation, contrast resolved with APCA.
|
|
106
|
+
|
|
107
|
+
## Docs and plans
|
|
108
|
+
|
|
109
|
+
- `docs/README.md`
|
|
110
|
+
- `docs/concepts.md`
|
|
111
|
+
- `docs/api.md`
|
|
112
|
+
- `docs/tokens.md`
|
|
113
|
+
- `docs/contrast.md`
|
|
114
|
+
- `docs/alpha.md`
|
|
115
|
+
- `docs/Why.md`
|
|
116
|
+
- `docs/spec-implementation.md`
|
|
117
|
+
- `docs/plan-tests.md`
|
|
118
|
+
- `docs/plan-docs.md`
|
|
119
|
+
|
|
120
|
+
## Short roadmap
|
|
121
|
+
|
|
122
|
+
1) Generate scales from seeds (light/dark).
|
|
123
|
+
2) Tokens and basic exporters.
|
|
124
|
+
3) Contrast and alpha scale.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
package/biome.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
|
3
|
+
"formatter": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"indentStyle": "space",
|
|
6
|
+
"indentWidth": 2,
|
|
7
|
+
"lineWidth": 100
|
|
8
|
+
},
|
|
9
|
+
"linter": {
|
|
10
|
+
"enabled": true,
|
|
11
|
+
"rules": {
|
|
12
|
+
"recommended": true
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": {
|
|
16
|
+
"ignoreUnknown": true,
|
|
17
|
+
"includes": [
|
|
18
|
+
"**/*.js",
|
|
19
|
+
"**/*.ts",
|
|
20
|
+
"**/*.tsx",
|
|
21
|
+
"**/*.css",
|
|
22
|
+
"**/*.scss",
|
|
23
|
+
"**/*.html",
|
|
24
|
+
"**/*.json",
|
|
25
|
+
"**/*.md",
|
|
26
|
+
"!**/supabase/functions",
|
|
27
|
+
"!**/node_modules",
|
|
28
|
+
"!**/dist",
|
|
29
|
+
"!**/build",
|
|
30
|
+
"!**/coverage",
|
|
31
|
+
"!**/ios",
|
|
32
|
+
"!**/android",
|
|
33
|
+
"!**/.git/",
|
|
34
|
+
"!**/.vscode",
|
|
35
|
+
"!**/*.d.ts",
|
|
36
|
+
"!**/*.spec.ts",
|
|
37
|
+
"!**/*.test.ts",
|
|
38
|
+
"!**/.github",
|
|
39
|
+
"!**/examples",
|
|
40
|
+
"!**/tests"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clhaas/palette-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Easy way to create the color palette of your app",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Claus Haas",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
|
|
17
|
+
"lint:biome": "biome check .",
|
|
18
|
+
"lint:md": "markdownlint \"**/*.md\" --ignore node_modules",
|
|
19
|
+
"lint": "npm run lint:biome && npm run lint:md && npm run typecheck && npm run typecheck:tests",
|
|
20
|
+
"update": "npx npm-check-updates -i"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"apca-w3": "^0.1.9",
|
|
24
|
+
"colorjs.io": "^0.6.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@biomejs/biome": "^2.3.11",
|
|
28
|
+
"@types/apca-w3": "^0.1.3",
|
|
29
|
+
"markdownlint-cli": "^0.47.0",
|
|
30
|
+
"typescript": "^5.9.3",
|
|
31
|
+
"vitest": "^4.0.16"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Color from "colorjs.io";
|
|
2
|
+
import type { AlphaScale, ColorHex, Step } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const steps: Step[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
|
5
|
+
const alphaCurve: Record<Step, number> = {
|
|
6
|
+
1: 0.05,
|
|
7
|
+
2: 0.1,
|
|
8
|
+
3: 0.15,
|
|
9
|
+
4: 0.2,
|
|
10
|
+
5: 0.3,
|
|
11
|
+
6: 0.4,
|
|
12
|
+
7: 0.5,
|
|
13
|
+
8: 0.6,
|
|
14
|
+
9: 0.7,
|
|
15
|
+
10: 0.8,
|
|
16
|
+
11: 0.9,
|
|
17
|
+
12: 0.95,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function mixWithAlpha(foreground: ColorHex, alpha: number): ColorHex {
|
|
21
|
+
const color = new Color(foreground).to("srgb");
|
|
22
|
+
const [r, g, b] = color.coords;
|
|
23
|
+
const hex = new Color({ space: "srgb", coords: [r, g, b], alpha }).toString({
|
|
24
|
+
format: "hex",
|
|
25
|
+
});
|
|
26
|
+
return hex as ColorHex;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function generateAlphaScale(
|
|
30
|
+
base: ColorHex,
|
|
31
|
+
_background: { light: ColorHex; dark: ColorHex },
|
|
32
|
+
): AlphaScale {
|
|
33
|
+
const light: Record<Step, ColorHex> = {} as Record<Step, ColorHex>;
|
|
34
|
+
const dark: Record<Step, ColorHex> = {} as Record<Step, ColorHex>;
|
|
35
|
+
|
|
36
|
+
for (const step of steps) {
|
|
37
|
+
const alpha = alphaCurve[step];
|
|
38
|
+
light[step] = mixWithAlpha(base, alpha);
|
|
39
|
+
dark[step] = mixWithAlpha(base, alpha);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { light, dark };
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { calcAPCA } from "apca-w3";
|
|
2
|
+
import type { ColorHex } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export function apcaContrast(foreground: ColorHex, background: ColorHex): number {
|
|
5
|
+
const contrast = Number(calcAPCA(foreground, background));
|
|
6
|
+
return Number(contrast.toFixed(2));
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ColorHex } from "../types.js";
|
|
2
|
+
import { apcaContrast } from "./apca.js";
|
|
3
|
+
|
|
4
|
+
const white: ColorHex = "#ffffff";
|
|
5
|
+
const black: ColorHex = "#000000";
|
|
6
|
+
|
|
7
|
+
const alphaLevels = {
|
|
8
|
+
primary: 0.92,
|
|
9
|
+
secondary: 0.72,
|
|
10
|
+
disabled: 0.48,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function withAlpha(hex: ColorHex, alpha: number): ColorHex {
|
|
14
|
+
const normalized = hex.replace("#", "");
|
|
15
|
+
const alphaHex = Math.round(alpha * 255)
|
|
16
|
+
.toString(16)
|
|
17
|
+
.padStart(2, "0");
|
|
18
|
+
return `#${normalized}${alphaHex}` as ColorHex;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function chooseTextColor(background: ColorHex): ColorHex {
|
|
22
|
+
const whiteScore = Math.abs(apcaContrast(white, background));
|
|
23
|
+
const blackScore = Math.abs(apcaContrast(black, background));
|
|
24
|
+
return whiteScore >= blackScore ? white : black;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function onSolidTextTokens(background: ColorHex): {
|
|
28
|
+
primary: ColorHex;
|
|
29
|
+
secondary: ColorHex;
|
|
30
|
+
disabled: ColorHex;
|
|
31
|
+
} {
|
|
32
|
+
const base = chooseTextColor(background);
|
|
33
|
+
return {
|
|
34
|
+
primary: withAlpha(base, alphaLevels.primary),
|
|
35
|
+
secondary: withAlpha(base, alphaLevels.secondary),
|
|
36
|
+
disabled: withAlpha(base, alphaLevels.disabled),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Color from "colorjs.io";
|
|
2
|
+
import { compressToSrgb, oklchToHex } from "../engine/oklch.js";
|
|
3
|
+
import type { ColorHex, OklchColor } from "../types.js";
|
|
4
|
+
import { apcaContrast } from "./apca.js";
|
|
5
|
+
|
|
6
|
+
function clamp(value: number, min: number, max: number): number {
|
|
7
|
+
return Math.min(max, Math.max(min, value));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function adjustLightness(
|
|
11
|
+
oklch: OklchColor,
|
|
12
|
+
background: ColorHex,
|
|
13
|
+
target: number,
|
|
14
|
+
maxIterations = 24,
|
|
15
|
+
): OklchColor {
|
|
16
|
+
const bg = new Color(background).to("oklch");
|
|
17
|
+
const bgL = bg.coords[0] ?? 0;
|
|
18
|
+
const direction = bgL > 0.5 ? -1 : 1;
|
|
19
|
+
let current = { ...oklch };
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < maxIterations; i += 1) {
|
|
22
|
+
const contrast = Math.abs(apcaContrast(oklchToHex(current), background));
|
|
23
|
+
if (contrast >= target) {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
current = {
|
|
28
|
+
...current,
|
|
29
|
+
l: clamp(current.l + direction * 0.02, 0, 1),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function adjustTextColor(
|
|
37
|
+
foreground: ColorHex,
|
|
38
|
+
background: ColorHex,
|
|
39
|
+
target: number,
|
|
40
|
+
): ColorHex {
|
|
41
|
+
const fg = new Color(foreground).to("oklch");
|
|
42
|
+
const [l, c, h] = fg.coords;
|
|
43
|
+
let candidate: OklchColor = { l: l ?? 0, c: c ?? 0, h: h ?? 0 };
|
|
44
|
+
|
|
45
|
+
candidate = adjustLightness(candidate, background, target);
|
|
46
|
+
candidate = compressToSrgb(candidate);
|
|
47
|
+
|
|
48
|
+
return oklchToHex(candidate);
|
|
49
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { generateAlphaScale } from "./alpha/generateAlphaScale.js";
|
|
2
|
+
import { onSolidTextTokens } from "./contrast/onSolid.js";
|
|
3
|
+
import { adjustTextColor } from "./contrast/solveText.js";
|
|
4
|
+
import { analyzeTheme } from "./diagnostics/analyzeTheme.js";
|
|
5
|
+
import { generateScale } from "./generateScale.js";
|
|
6
|
+
import { buildPresetTokens } from "./tokens/presetRadixLikeUi.js";
|
|
7
|
+
import type { AlphaScale, ColorHex, ColorSource, Scale, Theme } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export type TokenOverrides = {
|
|
10
|
+
light?: Record<string, ColorHex>;
|
|
11
|
+
dark?: Record<string, ColorHex>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CreateThemeOptions = {
|
|
15
|
+
neutral: ColorSource;
|
|
16
|
+
accent: ColorSource;
|
|
17
|
+
semantic?: {
|
|
18
|
+
success?: ColorSource;
|
|
19
|
+
warning?: ColorSource;
|
|
20
|
+
danger?: ColorSource;
|
|
21
|
+
};
|
|
22
|
+
extras?: Record<string, ColorSource>;
|
|
23
|
+
tokens?: { preset?: "radix-like-ui"; overrides?: TokenOverrides };
|
|
24
|
+
alpha?: {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
background?: { light?: ColorHex; dark?: ColorHex };
|
|
27
|
+
};
|
|
28
|
+
contrast?: {
|
|
29
|
+
textPrimary?: number;
|
|
30
|
+
textSecondary?: number;
|
|
31
|
+
};
|
|
32
|
+
p3?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createTheme(options: CreateThemeOptions): Theme {
|
|
36
|
+
const includeP3 = options.p3 ?? false;
|
|
37
|
+
const scales: Record<string, Scale> = {
|
|
38
|
+
neutral: generateScale({ source: options.neutral, p3: includeP3 }),
|
|
39
|
+
accent: generateScale({ source: options.accent, p3: includeP3 }),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (options.semantic?.success) {
|
|
43
|
+
scales.success = generateScale({ source: options.semantic.success, p3: includeP3 });
|
|
44
|
+
}
|
|
45
|
+
if (options.semantic?.warning) {
|
|
46
|
+
scales.warning = generateScale({ source: options.semantic.warning, p3: includeP3 });
|
|
47
|
+
}
|
|
48
|
+
if (options.semantic?.danger) {
|
|
49
|
+
scales.danger = generateScale({ source: options.semantic.danger, p3: includeP3 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (options.extras) {
|
|
53
|
+
for (const [key, source] of Object.entries(options.extras)) {
|
|
54
|
+
scales[key] = generateScale({ source, p3: includeP3 });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const preset = options.tokens?.preset ?? "radix-like-ui";
|
|
59
|
+
const tokens = preset === "radix-like-ui" ? buildPresetTokens(scales) : { light: {}, dark: {} };
|
|
60
|
+
|
|
61
|
+
const lightBg = tokens.light["bg.app"];
|
|
62
|
+
const darkBg = tokens.dark["bg.app"];
|
|
63
|
+
const textPrimaryTarget = options.contrast?.textPrimary ?? 75;
|
|
64
|
+
const textSecondaryTarget = options.contrast?.textSecondary ?? 60;
|
|
65
|
+
|
|
66
|
+
if (lightBg && tokens.light["text.primary"]) {
|
|
67
|
+
tokens.light["text.primary"] = adjustTextColor(
|
|
68
|
+
tokens.light["text.primary"],
|
|
69
|
+
lightBg,
|
|
70
|
+
textPrimaryTarget,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (lightBg && tokens.light["text.secondary"]) {
|
|
74
|
+
tokens.light["text.secondary"] = adjustTextColor(
|
|
75
|
+
tokens.light["text.secondary"],
|
|
76
|
+
lightBg,
|
|
77
|
+
textSecondaryTarget,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (darkBg && tokens.dark["text.primary"]) {
|
|
81
|
+
tokens.dark["text.primary"] = adjustTextColor(
|
|
82
|
+
tokens.dark["text.primary"],
|
|
83
|
+
darkBg,
|
|
84
|
+
textPrimaryTarget,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (darkBg && tokens.dark["text.secondary"]) {
|
|
88
|
+
tokens.dark["text.secondary"] = adjustTextColor(
|
|
89
|
+
tokens.dark["text.secondary"],
|
|
90
|
+
darkBg,
|
|
91
|
+
textSecondaryTarget,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const accentScale = scales.accent;
|
|
96
|
+
const lightOnSolid = onSolidTextTokens(accentScale.light[9]);
|
|
97
|
+
const darkOnSolid = onSolidTextTokens(accentScale.dark[9]);
|
|
98
|
+
|
|
99
|
+
tokens.light["onSolid.primary"] = lightOnSolid.primary;
|
|
100
|
+
tokens.light["onSolid.secondary"] = lightOnSolid.secondary;
|
|
101
|
+
tokens.light["onSolid.disabled"] = lightOnSolid.disabled;
|
|
102
|
+
tokens.dark["onSolid.primary"] = darkOnSolid.primary;
|
|
103
|
+
tokens.dark["onSolid.secondary"] = darkOnSolid.secondary;
|
|
104
|
+
tokens.dark["onSolid.disabled"] = darkOnSolid.disabled;
|
|
105
|
+
|
|
106
|
+
if (options.tokens?.overrides?.light) {
|
|
107
|
+
Object.assign(tokens.light, options.tokens.overrides.light);
|
|
108
|
+
}
|
|
109
|
+
if (options.tokens?.overrides?.dark) {
|
|
110
|
+
Object.assign(tokens.dark, options.tokens.overrides.dark);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let alpha: AlphaScale | undefined;
|
|
114
|
+
if (options.alpha?.enabled !== false) {
|
|
115
|
+
const background = {
|
|
116
|
+
light: options.alpha?.background?.light ?? "#ffffff",
|
|
117
|
+
dark: options.alpha?.background?.dark ?? "#111111",
|
|
118
|
+
} as const;
|
|
119
|
+
alpha = generateAlphaScale(accentScale.light[9], background);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const diagnostics = analyzeTheme({ scales, tokens, alpha });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
scales,
|
|
126
|
+
tokens,
|
|
127
|
+
alpha,
|
|
128
|
+
diagnostics,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ColorHex, RadixSeedName } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const radixSeeds: Record<RadixSeedName, ColorHex> = {
|
|
4
|
+
amber: "#ffc53d",
|
|
5
|
+
blue: "#0090ff",
|
|
6
|
+
bronze: "#a18072",
|
|
7
|
+
brown: "#ad7f58",
|
|
8
|
+
crimson: "#e93d82",
|
|
9
|
+
cyan: "#00a2c7",
|
|
10
|
+
gold: "#978365",
|
|
11
|
+
grass: "#46a758",
|
|
12
|
+
gray: "#8d8d8d",
|
|
13
|
+
green: "#30a46c",
|
|
14
|
+
indigo: "#3e63dd",
|
|
15
|
+
iris: "#5b5bd6",
|
|
16
|
+
jade: "#29a383",
|
|
17
|
+
lime: "#bdee63",
|
|
18
|
+
mauve: "#8e8c99",
|
|
19
|
+
mint: "#86ead4",
|
|
20
|
+
olive: "#898e87",
|
|
21
|
+
orange: "#f76b15",
|
|
22
|
+
pink: "#d6409f",
|
|
23
|
+
plum: "#ab4aba",
|
|
24
|
+
purple: "#8e4ec6",
|
|
25
|
+
red: "#e5484d",
|
|
26
|
+
ruby: "#e54666",
|
|
27
|
+
sage: "#868e8b",
|
|
28
|
+
sand: "#8d8d86",
|
|
29
|
+
sky: "#7ce2fe",
|
|
30
|
+
slate: "#8b8d98",
|
|
31
|
+
teal: "#12a594",
|
|
32
|
+
tomato: "#e54d2e",
|
|
33
|
+
violet: "#6e56cf",
|
|
34
|
+
yellow: "#ffe629",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const radixSeedNames = Object.keys(radixSeeds).sort();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { apcaContrast } from "../contrast/apca.js";
|
|
2
|
+
import type { Theme, ThemeDiagnostics } from "../types.js";
|
|
3
|
+
import { analyzeWarnings } from "./warnings.js";
|
|
4
|
+
|
|
5
|
+
export function analyzeTheme(theme: Theme): ThemeDiagnostics {
|
|
6
|
+
const contrast: Record<string, number> = {};
|
|
7
|
+
|
|
8
|
+
const lightBg = theme.tokens.light["bg.app"];
|
|
9
|
+
const darkBg = theme.tokens.dark["bg.app"];
|
|
10
|
+
|
|
11
|
+
if (lightBg) {
|
|
12
|
+
if (theme.tokens.light["text.primary"]) {
|
|
13
|
+
contrast["light.text.primary"] = apcaContrast(theme.tokens.light["text.primary"], lightBg);
|
|
14
|
+
}
|
|
15
|
+
if (theme.tokens.light["text.secondary"]) {
|
|
16
|
+
contrast["light.text.secondary"] = apcaContrast(
|
|
17
|
+
theme.tokens.light["text.secondary"],
|
|
18
|
+
lightBg,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (darkBg) {
|
|
24
|
+
if (theme.tokens.dark["text.primary"]) {
|
|
25
|
+
contrast["dark.text.primary"] = apcaContrast(theme.tokens.dark["text.primary"], darkBg);
|
|
26
|
+
}
|
|
27
|
+
if (theme.tokens.dark["text.secondary"]) {
|
|
28
|
+
contrast["dark.text.secondary"] = apcaContrast(theme.tokens.dark["text.secondary"], darkBg);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (theme.tokens.light["onSolid.primary"] && theme.tokens.light["accent.solid"]) {
|
|
33
|
+
contrast["light.onSolid.primary"] = apcaContrast(
|
|
34
|
+
theme.tokens.light["onSolid.primary"],
|
|
35
|
+
theme.tokens.light["accent.solid"],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (theme.tokens.dark["onSolid.primary"] && theme.tokens.dark["accent.solid"]) {
|
|
40
|
+
contrast["dark.onSolid.primary"] = apcaContrast(
|
|
41
|
+
theme.tokens.dark["onSolid.primary"],
|
|
42
|
+
theme.tokens.dark["accent.solid"],
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let outOfGamutCount = 0;
|
|
47
|
+
for (const scale of Object.values(theme.scales)) {
|
|
48
|
+
outOfGamutCount += scale.meta?.outOfGamutCount ?? 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const warnings = analyzeWarnings(theme);
|
|
52
|
+
|
|
53
|
+
return { contrast, outOfGamutCount, warnings };
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Color from "colorjs.io";
|
|
2
|
+
import type { Theme } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export function analyzeWarnings(theme: Theme): string[] {
|
|
5
|
+
const warnings: string[] = [];
|
|
6
|
+
|
|
7
|
+
const accent = theme.scales.accent?.light?.[9];
|
|
8
|
+
if (accent) {
|
|
9
|
+
const { coords } = new Color(accent).to("oklch");
|
|
10
|
+
const l = coords[0] ?? 0;
|
|
11
|
+
const c = coords[1] ?? 0;
|
|
12
|
+
|
|
13
|
+
if (c < 0.03) {
|
|
14
|
+
warnings.push("Accent seed has very low chroma; the palette may look gray.");
|
|
15
|
+
}
|
|
16
|
+
if (l < 0.2) {
|
|
17
|
+
warnings.push("Accent seed is very dark; light mode solids may lack contrast.");
|
|
18
|
+
}
|
|
19
|
+
if (l > 0.9) {
|
|
20
|
+
warnings.push("Accent seed is very light; dark mode solids may lack contrast.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return warnings;
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Step } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export type CurveConfig = {
|
|
4
|
+
lightness?: Partial<Record<Step, number>>;
|
|
5
|
+
chroma?: Partial<Record<Step, number>>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const steps: Step[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
|
9
|
+
|
|
10
|
+
const defaultLightness: Record<Step, number> = {
|
|
11
|
+
1: 0.3,
|
|
12
|
+
2: 0.3,
|
|
13
|
+
3: 0.6,
|
|
14
|
+
4: 0.6,
|
|
15
|
+
5: 0.6,
|
|
16
|
+
6: 0.85,
|
|
17
|
+
7: 0.85,
|
|
18
|
+
8: 0.85,
|
|
19
|
+
9: 1,
|
|
20
|
+
10: 1,
|
|
21
|
+
11: 1,
|
|
22
|
+
12: 1,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const defaultChroma: Record<Step, number> = {
|
|
26
|
+
1: 0.2,
|
|
27
|
+
2: 0.2,
|
|
28
|
+
3: 0.6,
|
|
29
|
+
4: 0.6,
|
|
30
|
+
5: 0.6,
|
|
31
|
+
6: 0.8,
|
|
32
|
+
7: 0.8,
|
|
33
|
+
8: 0.8,
|
|
34
|
+
9: 1,
|
|
35
|
+
10: 1,
|
|
36
|
+
11: 0.7,
|
|
37
|
+
12: 0.7,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function resolveCurves(curves?: CurveConfig): {
|
|
41
|
+
lightness: Record<Step, number>;
|
|
42
|
+
chroma: Record<Step, number>;
|
|
43
|
+
} {
|
|
44
|
+
const lightness = { ...defaultLightness };
|
|
45
|
+
const chroma = { ...defaultChroma };
|
|
46
|
+
|
|
47
|
+
if (curves?.lightness) {
|
|
48
|
+
for (const step of steps) {
|
|
49
|
+
if (curves.lightness[step] !== undefined) {
|
|
50
|
+
lightness[step] = curves.lightness[step] as number;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (curves?.chroma) {
|
|
56
|
+
for (const step of steps) {
|
|
57
|
+
if (curves.chroma[step] !== undefined) {
|
|
58
|
+
chroma[step] = curves.chroma[step] as number;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { lightness, chroma };
|
|
64
|
+
}
|