chroma-panel 0.2.0 → 1.0.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 (92) hide show
  1. package/README.md +46 -8
  2. package/dist/a11y/contrast.cjs +94 -0
  3. package/dist/a11y/contrast.d.cts +8 -0
  4. package/dist/a11y/contrast.d.ts +8 -0
  5. package/dist/a11y/contrast.js +93 -2
  6. package/dist/color/css4.cjs +397 -0
  7. package/dist/color/css4.d.cts +10 -0
  8. package/dist/color/css4.d.ts +10 -0
  9. package/dist/color/css4.js +392 -0
  10. package/dist/color/parse.cjs +3 -1
  11. package/dist/color/parse.js +3 -1
  12. package/dist/color/types.d.cts +16 -0
  13. package/dist/color/types.d.ts +16 -0
  14. package/dist/color/value.cjs +11 -0
  15. package/dist/color/value.d.cts +4 -0
  16. package/dist/color/value.d.ts +4 -0
  17. package/dist/color/value.js +12 -0
  18. package/dist/color.cjs +43 -0
  19. package/dist/color.d.cts +9 -0
  20. package/dist/color.d.ts +9 -0
  21. package/dist/color.js +8 -0
  22. package/dist/components/ChromaPanel.cjs +16 -6
  23. package/dist/components/ChromaPanel.d.cts +4 -2
  24. package/dist/components/ChromaPanel.d.ts +4 -2
  25. package/dist/components/ChromaPanel.js +16 -6
  26. package/dist/components/ColorInput.cjs +1 -1
  27. package/dist/components/ColorInput.js +1 -1
  28. package/dist/components/PanelFooter.cjs +4 -4
  29. package/dist/components/PanelFooter.js +4 -4
  30. package/dist/contrast.cjs +3 -0
  31. package/dist/contrast.d.cts +2 -2
  32. package/dist/contrast.d.ts +2 -2
  33. package/dist/contrast.js +2 -2
  34. package/dist/core/store.cjs +17 -9
  35. package/dist/core/store.d.cts +6 -6
  36. package/dist/core/store.d.ts +6 -6
  37. package/dist/core/store.js +17 -9
  38. package/dist/core.cjs +10 -0
  39. package/dist/core.d.cts +4 -2
  40. package/dist/core.d.ts +4 -2
  41. package/dist/core.js +3 -1
  42. package/dist/export/tokens.cjs +39 -0
  43. package/dist/export/tokens.d.cts +16 -0
  44. package/dist/export/tokens.d.ts +16 -0
  45. package/dist/export/tokens.js +37 -0
  46. package/dist/export.cjs +6 -0
  47. package/dist/export.d.cts +2 -0
  48. package/dist/export.d.ts +2 -0
  49. package/dist/export.js +2 -0
  50. package/dist/gradient/GradientEditor.cjs +196 -0
  51. package/dist/gradient/GradientEditor.d.cts +17 -0
  52. package/dist/gradient/GradientEditor.d.ts +17 -0
  53. package/dist/gradient/GradientEditor.js +195 -0
  54. package/dist/gradient/model.cjs +63 -0
  55. package/dist/gradient/model.d.cts +23 -0
  56. package/dist/gradient/model.d.ts +23 -0
  57. package/dist/gradient/model.js +60 -0
  58. package/dist/gradient.cjs +9 -0
  59. package/dist/gradient.d.cts +3 -0
  60. package/dist/gradient.d.ts +3 -0
  61. package/dist/gradient.js +3 -0
  62. package/dist/index.cjs +13 -0
  63. package/dist/index.d.cts +5 -3
  64. package/dist/index.d.ts +5 -3
  65. package/dist/index.js +4 -2
  66. package/dist/modes/image/index.cjs +65 -4
  67. package/dist/modes/image/index.d.cts +1 -0
  68. package/dist/modes/image/index.d.ts +1 -0
  69. package/dist/modes/image/index.js +65 -4
  70. package/dist/modes/registry.d.cts +1 -1
  71. package/dist/modes/registry.d.ts +1 -1
  72. package/dist/modes.cjs +30 -0
  73. package/dist/modes.d.cts +10 -0
  74. package/dist/modes.d.ts +10 -0
  75. package/dist/modes.js +17 -0
  76. package/dist/panel.cjs +10 -0
  77. package/dist/panel.d.cts +4 -2
  78. package/dist/panel.d.ts +4 -2
  79. package/dist/panel.js +3 -1
  80. package/dist/primitives/ChannelSlider.cjs +4 -4
  81. package/dist/primitives/ChannelSlider.js +4 -4
  82. package/dist/primitives/ColorArea.cjs +6 -6
  83. package/dist/primitives/ColorArea.js +6 -6
  84. package/dist/primitives/ColorDisc.cjs +6 -6
  85. package/dist/primitives/ColorDisc.js +6 -6
  86. package/dist/primitives/ColorField.cjs +2 -2
  87. package/dist/primitives/ColorField.js +2 -2
  88. package/dist/primitives/NumberField.cjs +2 -2
  89. package/dist/primitives/NumberField.js +2 -2
  90. package/dist/primitives/SwatchGrid.cjs +2 -2
  91. package/dist/primitives/SwatchGrid.js +2 -2
  92. package/package.json +46 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  # chroma-panel — React color picker
10
10
 
11
- A lightweight, accessible React color picker component with the look and feel of the macOS color panel. It includes a color wheel, precise RGB, HSL and HSV sliders, searchable swatches, image color sampling, opacity and an eyedropper—with zero runtime dependencies and TypeScript types included.
11
+ A lightweight, accessible React color picker component with the look and feel of the macOS color panel. Version 1 adds CSS Color 4, a gradient editor, accessible contrast suggestions and design-token exports alongside the color wheel, precise sliders, searchable swatches, image sampling, opacity and eyedropper—with zero runtime dependencies and TypeScript types included.
12
12
 
13
13
  **[Documentation and live demo](https://chroma-panel.jscrate.dev)** · [Quick start](https://chroma-panel.jscrate.dev/react/overview/quick-start) · [Comparison](https://chroma-panel.jscrate.dev/react/overview/comparison) · [FAQ](https://chroma-panel.jscrate.dev/react/overview/faq) · [Releases](https://chroma-panel.jscrate.dev/react/overview/releases)
14
14
 
@@ -18,6 +18,9 @@ A lightweight, accessible React color picker component with the look and feel of
18
18
  - Drops into a [form](https://chroma-panel.jscrate.dev/react/handbook/forms) like an `<input>`, with `name`, `required` and `form.reset()`
19
19
  - [Accessible](https://chroma-panel.jscrate.dev/react/overview/accessibility): every color channel is a real range input, so keyboards and screen readers work
20
20
  - No runtime dependencies, TypeScript types included, ESM and CommonJS
21
+ - CSS Color 4: OKLCH, OKLab, Lab, LCH and Display P3 parsing, conversion and gamut mapping
22
+ - A standalone, keyboard-accessible gradient editor with perceptual interpolation
23
+ - Alpha-aware contrast checks, accessible-color suggestions and CSS/SCSS/design-token exports
21
24
 
22
25
  ## Install
23
26
 
@@ -60,7 +63,7 @@ import { ChromaPanel } from 'chroma-panel';
60
63
 
61
64
  ## Pick colors from an image
62
65
 
63
- The image mode takes a dropped or chosen file, shows its dominant colors as swatches, and lets you click an exact pixel through a zoom lens. The sampler behind it is exported too, for when you want the palette without the panel:
66
+ The image mode takes a dropped, pasted or chosen file, shows its dominant colors as swatches, and lets you click an exact pixel through a zoom lens. Palettes can be ordered by population, luminance or hue. The sampler behind it is exported too, for when you want the palette without the panel:
64
67
 
65
68
  ```ts
66
69
  import { extractPalette } from 'chroma-panel';
@@ -73,16 +76,51 @@ It takes a `File`, a `Blob` or an image URL, validates safe size limits, and rea
73
76
 
74
77
  ## Smaller bundle
75
78
 
76
- Importing `chroma-panel` registers all five modes. If you only need one or two, import the shell and add them yourself:
79
+ Importing `chroma-panel` registers all five modes. If you only need one or two, import the shell and pass explicit mode objects:
77
80
 
78
81
  ```tsx
79
82
  import { ChromaPanel } from 'chroma-panel/panel';
80
- import 'chroma-panel/wheel';
83
+ import { wheelMode } from 'chroma-panel/modes';
81
84
 
82
- <ChromaPanel modes={['wheel']} />
85
+ <ChromaPanel modes={[wheelMode]} />
83
86
  ```
84
87
 
85
- That is 13.0 kB instead of 20.6 kB. Every mode has its own entry point — see [entry points](https://chroma-panel.jscrate.dev/react/utils/entry-points).
88
+ The original side-effect import (`import 'chroma-panel/wheel'`) remains supported. Explicit mode objects are easier for bundlers to analyze. Every mode also has its own entry point — see [entry points](https://chroma-panel.jscrate.dev/react/utils/entry-points).
89
+
90
+ ## CSS Color 4 and wide gamut
91
+
92
+ The dependency-free color engine reads and converts `oklch()`, `oklab()`, `lab()`, `lch()`, `color(srgb …)` and `color(display-p3 …)`. Gamut checks do not silently clip wide-gamut input:
93
+
94
+ ```ts
95
+ import { parseColor, convertColor, isInGamut, mapToGamut, serializeColor } from 'chroma-panel/color';
96
+
97
+ const color = parseColor('oklch(72% 0.18 250)')!;
98
+ isInGamut(color, 'srgb');
99
+ serializeColor(mapToGamut(color, 'display-p3'));
100
+ ```
101
+
102
+ ## Gradient editor
103
+
104
+ ```tsx
105
+ import { GradientEditor, type GradientValue } from 'chroma-panel/gradient';
106
+
107
+ <GradientEditor defaultValue={gradient} onChangeComplete={(value) => save(value)} />
108
+ ```
109
+
110
+ The same entry exports `gradientToCss`, `sampleGradient`, `normalizeGradient` and `addGradientStop`. It supports linear and radial gradients, movable color stops and OKLab/OKLCH interpolation.
111
+
112
+ ## Reliable change events
113
+
114
+ The original `onChange` and `onChangeComplete` callbacks remain unchanged. The new callbacks include interaction metadata:
115
+
116
+ ```tsx
117
+ <ColorInput
118
+ onValueChange={(color, meta) => preview(color.css, meta.source)}
119
+ onValueCommit={(color, meta) => save(color.css, meta.source)}
120
+ />
121
+ ```
122
+
123
+ `meta.source` distinguishes pointer, keyboard, field, swatch, image, eyedropper, recent-color and programmatic changes.
86
124
 
87
125
  ## Theming
88
126
 
@@ -108,8 +146,8 @@ Measured as the increase in a real Vite production build, gzipped, with React ex
108
146
 
109
147
  | What you import | Added to your app |
110
148
  | --- | --- |
111
- | all five modes | 19.3 kB |
112
- | shell plus one mode | 12.5 kB |
149
+ | all five modes | 22.9 kB |
150
+ | shell plus one mode | 14.9 kB |
113
151
 
114
152
  `dependencies` is empty. `react` and `react-dom` are peer dependencies, so the copy already in your app is the one that gets used.
115
153
 
@@ -1,6 +1,9 @@
1
1
  const require_dev = require("../core/dev.cjs");
2
2
  const require_convert = require("../color/convert.cjs");
3
+ const require_css4 = require("../color/css4.cjs");
3
4
  const require_parse = require("../color/parse.cjs");
5
+ const require_serialize = require("../color/serialize.cjs");
6
+ const require_value = require("../color/value.cjs");
4
7
  //#region src/a11y/contrast.ts
5
8
  function toRgb(input) {
6
9
  if (typeof input !== "string") return require_convert.hsvaToRgba(input);
@@ -23,6 +26,94 @@ function contrastRatio(a, b) {
23
26
  const dark = Math.min(la, lb);
24
27
  return (light + .05) / (dark + .05);
25
28
  }
29
+ function compositeColor(foreground, background) {
30
+ const fg = typeof foreground === "string" ? require_parse.parse(foreground) : foreground;
31
+ const bg = typeof background === "string" ? require_parse.parse(background) : background;
32
+ if (fg === null || bg === null) return null;
33
+ const f = require_convert.hsvaToRgba(fg);
34
+ const b = require_convert.hsvaToRgba(bg);
35
+ const alpha = f.a + b.a * (1 - f.a);
36
+ if (alpha === 0) return {
37
+ h: 0,
38
+ s: 0,
39
+ v: 0,
40
+ a: 0
41
+ };
42
+ return require_convert.rgbaToHsva({
43
+ r: (f.r * f.a + b.r * b.a * (1 - f.a)) / alpha,
44
+ g: (f.g * f.a + b.g * b.a * (1 - f.a)) / alpha,
45
+ b: (f.b * f.a + b.b * b.a * (1 - f.a)) / alpha,
46
+ a: alpha
47
+ });
48
+ }
49
+ function contrastRatioWithAlpha(foreground, background, canvas = "#ffffff") {
50
+ const opaqueBackground = compositeColor(background, canvas);
51
+ if (opaqueBackground === null) return 1;
52
+ const opaqueForeground = compositeColor(foreground, opaqueBackground);
53
+ return opaqueForeground === null ? 1 : contrastRatio(opaqueForeground, opaqueBackground);
54
+ }
55
+ function suggestAccessibleColor(foreground, background, targetRatio = 4.5) {
56
+ const source = require_value.parseColor(foreground);
57
+ if (source === null || require_parse.parse(background) === null || !Number.isFinite(targetRatio) || targetRatio < 1) return null;
58
+ const currentRatio = contrastRatioWithAlpha(foreground, background);
59
+ if (currentRatio >= targetRatio) return {
60
+ color: foreground,
61
+ ratio: currentRatio,
62
+ changed: false
63
+ };
64
+ const oklch = require_css4.convertColor(source, "oklch");
65
+ const candidates = [];
66
+ for (const endpoint of [0, 1]) {
67
+ let passing = endpoint;
68
+ let failing = oklch.channels[0];
69
+ if (contrastRatio(toHexColor({
70
+ ...oklch,
71
+ channels: [
72
+ endpoint,
73
+ oklch.channels[1],
74
+ oklch.channels[2]
75
+ ],
76
+ alpha: 1
77
+ }), background) < targetRatio) continue;
78
+ for (let index = 0; index < 24; index++) {
79
+ const lightness = (passing + failing) / 2;
80
+ if (contrastRatio(toHexColor({
81
+ ...oklch,
82
+ channels: [
83
+ lightness,
84
+ oklch.channels[1],
85
+ oklch.channels[2]
86
+ ],
87
+ alpha: 1
88
+ }), background) >= targetRatio) passing = lightness;
89
+ else failing = lightness;
90
+ }
91
+ const color = toHexColor({
92
+ ...oklch,
93
+ channels: [
94
+ passing,
95
+ oklch.channels[1],
96
+ oklch.channels[2]
97
+ ],
98
+ alpha: 1
99
+ });
100
+ candidates.push({
101
+ color,
102
+ ratio: contrastRatio(color, background),
103
+ changed: true
104
+ });
105
+ }
106
+ return candidates.sort((a, b) => Math.abs(require_css4.convertColor(require_value.parseColor(a.color), "oklch").channels[0] - oklch.channels[0]) - Math.abs(require_css4.convertColor(require_value.parseColor(b.color), "oklch").channels[0] - oklch.channels[0]))[0] ?? null;
107
+ }
108
+ function toHexColor(color) {
109
+ const srgb = require_css4.mapToGamut(color, "srgb");
110
+ return require_serialize.toHex(require_convert.rgbaToHsva({
111
+ r: srgb.channels[0] * 255,
112
+ g: srgb.channels[1] * 255,
113
+ b: srgb.channels[2] * 255,
114
+ a: 1
115
+ }));
116
+ }
26
117
  function meetsContrast(a, b, options) {
27
118
  const { level, size = "normal" } = options;
28
119
  const ratio = contrastRatio(a, b);
@@ -98,10 +189,13 @@ function readableTextColor(background, options = {}) {
98
189
  }
99
190
  //#endregion
100
191
  exports.apcaContrast = apcaContrast;
192
+ exports.compositeColor = compositeColor;
101
193
  exports.contrastRatio = contrastRatio;
194
+ exports.contrastRatioWithAlpha = contrastRatioWithAlpha;
102
195
  exports.contrastReport = contrastReport;
103
196
  exports.meetsContrast = meetsContrast;
104
197
  exports.meetsNonTextContrast = meetsNonTextContrast;
105
198
  exports.readableTextColor = readableTextColor;
106
199
  exports.relativeLuminance = relativeLuminance;
200
+ exports.suggestAccessibleColor = suggestAccessibleColor;
107
201
  exports.wcagLevel = wcagLevel;
@@ -2,6 +2,14 @@ import { Hsva } from "../color/types.cjs";
2
2
  //#region src/a11y/contrast.d.ts
3
3
  export declare function relativeLuminance(color: string | Hsva): number;
4
4
  export declare function contrastRatio(a: string | Hsva, b: string | Hsva): number;
5
+ export declare function compositeColor(foreground: string | Hsva, background: string | Hsva): Hsva | null;
6
+ export declare function contrastRatioWithAlpha(foreground: string | Hsva, background: string | Hsva, canvas?: string | Hsva): number;
7
+ export interface AccessibleColorSuggestion {
8
+ color: string;
9
+ ratio: number;
10
+ changed: boolean;
11
+ }
12
+ export declare function suggestAccessibleColor(foreground: string, background: string, targetRatio?: number): AccessibleColorSuggestion | null;
5
13
  export type TextSize = "normal" | "large";
6
14
  export interface ContrastOptions {
7
15
  level: "AA" | "AAA";
@@ -2,6 +2,14 @@ import { Hsva } from "../color/types.js";
2
2
  //#region src/a11y/contrast.d.ts
3
3
  export declare function relativeLuminance(color: string | Hsva): number;
4
4
  export declare function contrastRatio(a: string | Hsva, b: string | Hsva): number;
5
+ export declare function compositeColor(foreground: string | Hsva, background: string | Hsva): Hsva | null;
6
+ export declare function contrastRatioWithAlpha(foreground: string | Hsva, background: string | Hsva, canvas?: string | Hsva): number;
7
+ export interface AccessibleColorSuggestion {
8
+ color: string;
9
+ ratio: number;
10
+ changed: boolean;
11
+ }
12
+ export declare function suggestAccessibleColor(foreground: string, background: string, targetRatio?: number): AccessibleColorSuggestion | null;
5
13
  export type TextSize = "normal" | "large";
6
14
  export interface ContrastOptions {
7
15
  level: "AA" | "AAA";
@@ -1,6 +1,9 @@
1
1
  import { warnOnce } from "../core/dev.js";
2
- import { hsvaToRgba } from "../color/convert.js";
2
+ import { hsvaToRgba, rgbaToHsva } from "../color/convert.js";
3
+ import { convertColor, mapToGamut } from "../color/css4.js";
3
4
  import { parse } from "../color/parse.js";
5
+ import { toHex } from "../color/serialize.js";
6
+ import { parseColor } from "../color/value.js";
4
7
  //#region src/a11y/contrast.ts
5
8
  function toRgb(input) {
6
9
  if (typeof input !== "string") return hsvaToRgba(input);
@@ -23,6 +26,94 @@ function contrastRatio(a, b) {
23
26
  const dark = Math.min(la, lb);
24
27
  return (light + .05) / (dark + .05);
25
28
  }
29
+ function compositeColor(foreground, background) {
30
+ const fg = typeof foreground === "string" ? parse(foreground) : foreground;
31
+ const bg = typeof background === "string" ? parse(background) : background;
32
+ if (fg === null || bg === null) return null;
33
+ const f = hsvaToRgba(fg);
34
+ const b = hsvaToRgba(bg);
35
+ const alpha = f.a + b.a * (1 - f.a);
36
+ if (alpha === 0) return {
37
+ h: 0,
38
+ s: 0,
39
+ v: 0,
40
+ a: 0
41
+ };
42
+ return rgbaToHsva({
43
+ r: (f.r * f.a + b.r * b.a * (1 - f.a)) / alpha,
44
+ g: (f.g * f.a + b.g * b.a * (1 - f.a)) / alpha,
45
+ b: (f.b * f.a + b.b * b.a * (1 - f.a)) / alpha,
46
+ a: alpha
47
+ });
48
+ }
49
+ function contrastRatioWithAlpha(foreground, background, canvas = "#ffffff") {
50
+ const opaqueBackground = compositeColor(background, canvas);
51
+ if (opaqueBackground === null) return 1;
52
+ const opaqueForeground = compositeColor(foreground, opaqueBackground);
53
+ return opaqueForeground === null ? 1 : contrastRatio(opaqueForeground, opaqueBackground);
54
+ }
55
+ function suggestAccessibleColor(foreground, background, targetRatio = 4.5) {
56
+ const source = parseColor(foreground);
57
+ if (source === null || parse(background) === null || !Number.isFinite(targetRatio) || targetRatio < 1) return null;
58
+ const currentRatio = contrastRatioWithAlpha(foreground, background);
59
+ if (currentRatio >= targetRatio) return {
60
+ color: foreground,
61
+ ratio: currentRatio,
62
+ changed: false
63
+ };
64
+ const oklch = convertColor(source, "oklch");
65
+ const candidates = [];
66
+ for (const endpoint of [0, 1]) {
67
+ let passing = endpoint;
68
+ let failing = oklch.channels[0];
69
+ if (contrastRatio(toHexColor({
70
+ ...oklch,
71
+ channels: [
72
+ endpoint,
73
+ oklch.channels[1],
74
+ oklch.channels[2]
75
+ ],
76
+ alpha: 1
77
+ }), background) < targetRatio) continue;
78
+ for (let index = 0; index < 24; index++) {
79
+ const lightness = (passing + failing) / 2;
80
+ if (contrastRatio(toHexColor({
81
+ ...oklch,
82
+ channels: [
83
+ lightness,
84
+ oklch.channels[1],
85
+ oklch.channels[2]
86
+ ],
87
+ alpha: 1
88
+ }), background) >= targetRatio) passing = lightness;
89
+ else failing = lightness;
90
+ }
91
+ const color = toHexColor({
92
+ ...oklch,
93
+ channels: [
94
+ passing,
95
+ oklch.channels[1],
96
+ oklch.channels[2]
97
+ ],
98
+ alpha: 1
99
+ });
100
+ candidates.push({
101
+ color,
102
+ ratio: contrastRatio(color, background),
103
+ changed: true
104
+ });
105
+ }
106
+ return candidates.sort((a, b) => Math.abs(convertColor(parseColor(a.color), "oklch").channels[0] - oklch.channels[0]) - Math.abs(convertColor(parseColor(b.color), "oklch").channels[0] - oklch.channels[0]))[0] ?? null;
107
+ }
108
+ function toHexColor(color) {
109
+ const srgb = mapToGamut(color, "srgb");
110
+ return toHex(rgbaToHsva({
111
+ r: srgb.channels[0] * 255,
112
+ g: srgb.channels[1] * 255,
113
+ b: srgb.channels[2] * 255,
114
+ a: 1
115
+ }));
116
+ }
26
117
  function meetsContrast(a, b, options) {
27
118
  const { level, size = "normal" } = options;
28
119
  const ratio = contrastRatio(a, b);
@@ -97,5 +188,5 @@ function readableTextColor(background, options = {}) {
97
188
  return Math.abs(apcaContrast(dark, background)) >= Math.abs(apcaContrast(light, background)) ? dark : light;
98
189
  }
99
190
  //#endregion
100
- export { apcaContrast, contrastRatio, contrastReport, meetsContrast, meetsNonTextContrast, readableTextColor, relativeLuminance, wcagLevel };
191
+ export { apcaContrast, compositeColor, contrastRatio, contrastRatioWithAlpha, contrastReport, meetsContrast, meetsNonTextContrast, readableTextColor, relativeLuminance, suggestAccessibleColor, wcagLevel };
101
192