@nextlyhq/ui 0.0.2-alpha.6 → 0.0.2-alpha.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -23
- package/dist/color.cjs +193 -0
- package/dist/color.cjs.map +1 -0
- package/dist/color.d.cts +251 -0
- package/dist/color.d.ts +251 -0
- package/dist/color.mjs +179 -0
- package/dist/color.mjs.map +1 -0
- package/dist/index.cjs +3402 -1089
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1475 -315
- package/dist/index.d.ts +1475 -315
- package/dist/index.mjs +3137 -833
- package/dist/index.mjs.map +1 -1
- package/dist/styles.css +2 -0
- package/dist/styles.scoped.css +2 -0
- package/dist/tailwind-preset.cjs +153 -0
- package/dist/tailwind-preset.cjs.map +1 -0
- package/dist/tailwind-preset.d.cts +120 -0
- package/dist/tailwind-preset.d.ts +120 -0
- package/dist/tailwind-preset.mjs +148 -0
- package/dist/tailwind-preset.mjs.map +1 -0
- package/dist/theme.css +1249 -0
- package/dist/utils.cjs +13 -0
- package/dist/utils.cjs.map +1 -0
- package/dist/utils.d.cts +19 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.mjs +11 -0
- package/dist/utils.mjs.map +1 -0
- package/docs/plugin-ui-authoring.md +230 -0
- package/package.json +78 -17
package/dist/color.d.cts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversions between the colour models an editing surface needs.
|
|
3
|
+
*
|
|
4
|
+
* ## Why each model is here
|
|
5
|
+
*
|
|
6
|
+
* - **sRGB** is what a screen displays and what `#rrggbb` encodes. Everything ends here.
|
|
7
|
+
* - **HSV** is what a colour picker is: a square of saturation against value, beside a hue
|
|
8
|
+
* strip. No other model puts those three controls in the shape people expect.
|
|
9
|
+
* - **OKLCH** is what this product's own theme is written in — every token in `theme.css` is an
|
|
10
|
+
* `oklch()` value. A picker that cannot read it cannot edit the theme.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the maths is here rather than in a library
|
|
13
|
+
*
|
|
14
|
+
* These are fixed, published transforms: the sRGB transfer function and the OKLab matrices are
|
|
15
|
+
* specified in CSS Color 4 and do not change. The package ships no runtime colour dependency, and
|
|
16
|
+
* this module keeps it that way. `culori` remains a DEV dependency used as a test oracle — the
|
|
17
|
+
* conversions here are cross-checked against it rather than trusted on their own.
|
|
18
|
+
*
|
|
19
|
+
* ## The part that is genuinely hard
|
|
20
|
+
*
|
|
21
|
+
* OKLCH describes more colours than a screen can show. Converting one that falls outside sRGB has
|
|
22
|
+
* no correct answer, only choices, and the naive choice — clamp each channel independently — is
|
|
23
|
+
* the wrong one: it shifts hue, because clipping red without clipping green changes the ratio
|
|
24
|
+
* between them. {@link oklchToRgb} reduces chroma instead, holding lightness and hue, which is the
|
|
25
|
+
* approach CSS Color 4 describes for gamut mapping.
|
|
26
|
+
*
|
|
27
|
+
* @module lib/color/convert
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* An sRGB colour with channels in [0, 1]. Alpha is carried separately.
|
|
31
|
+
*
|
|
32
|
+
* @experimental
|
|
33
|
+
*/
|
|
34
|
+
interface Rgb {
|
|
35
|
+
r: number;
|
|
36
|
+
g: number;
|
|
37
|
+
b: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Hue in [0, 360), saturation and value in [0, 1].
|
|
41
|
+
*
|
|
42
|
+
* @experimental
|
|
43
|
+
*/
|
|
44
|
+
interface Hsv {
|
|
45
|
+
h: number;
|
|
46
|
+
s: number;
|
|
47
|
+
v: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Perceptual lightness in [0, 1], chroma from 0, hue in [0, 360).
|
|
51
|
+
*
|
|
52
|
+
* @experimental
|
|
53
|
+
*/
|
|
54
|
+
interface Oklch {
|
|
55
|
+
l: number;
|
|
56
|
+
c: number;
|
|
57
|
+
h: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wrap a hue into [0, 360), so -30 and 330 are the same angle.
|
|
61
|
+
*
|
|
62
|
+
* @experimental
|
|
63
|
+
*/
|
|
64
|
+
declare function normalizeHue(hue: number): number;
|
|
65
|
+
/**
|
|
66
|
+
* HSV to sRGB.
|
|
67
|
+
* Saturation and value are clamped rather than rejected: a picker drags them, and a drag that
|
|
68
|
+
* overshoots by a rounding error should saturate rather than throw.
|
|
69
|
+
*
|
|
70
|
+
* @experimental
|
|
71
|
+
*/
|
|
72
|
+
declare function hsvToRgb({ h, s, v }: Hsv): Rgb;
|
|
73
|
+
/**
|
|
74
|
+
* sRGB to HSV.
|
|
75
|
+
* Hue is UNDEFINED for a grey and value is undefined for black, and this returns 0 for both. A
|
|
76
|
+
* picker must not take that 0 as the user's hue: it is the absence of one. Holding hue across a
|
|
77
|
+
* drag to zero saturation is the caller's job, which is why a picker keeps HSV as its state
|
|
78
|
+
* rather than deriving it from the colour each render.
|
|
79
|
+
*
|
|
80
|
+
* @experimental
|
|
81
|
+
*/
|
|
82
|
+
declare function rgbToHsv({ r, g, b }: Rgb): Hsv;
|
|
83
|
+
/**
|
|
84
|
+
* sRGB to OKLCH.
|
|
85
|
+
*
|
|
86
|
+
* @experimental
|
|
87
|
+
*/
|
|
88
|
+
declare function rgbToOklch({ r, g, b }: Rgb): Oklch;
|
|
89
|
+
/**
|
|
90
|
+
* OKLCH to sRGB, reducing chroma until the colour fits on screen.
|
|
91
|
+
* OKLCH can name colours a display cannot show. Clamping the channels independently is the
|
|
92
|
+
* obvious response and the wrong one: clipping red without clipping green changes the ratio
|
|
93
|
+
* between them, so the colour that appears is a DIFFERENT HUE from the one asked for. Lightness
|
|
94
|
+
* and hue are what a person chose; chroma is the part they will not miss, so chroma is what is
|
|
95
|
+
* given up.
|
|
96
|
+
* The search is a bisection on chroma, which converges to well under a perceptible step in the
|
|
97
|
+
* fixed number of rounds below — no loop that might not terminate.
|
|
98
|
+
*
|
|
99
|
+
* **The gamut is not always monotonic along this ray, and that is a deliberate trade.** At some
|
|
100
|
+
* lightness and hue pairs the boundary is crossed more than once: at `l = 0.2, h = 264.1` the ray
|
|
101
|
+
* is inside up to c ≈ 0.1192, outside until c ≈ 0.1368, briefly inside again to c ≈ 0.1386. A
|
|
102
|
+
* bisection therefore finds the FIRST boundary rather than the outermost reachable chroma.
|
|
103
|
+
*
|
|
104
|
+
* That is the intended behaviour rather than a limitation. Those islands span roughly a tenth of
|
|
105
|
+
* a degree of hue, so preferring the outermost value would make chroma jump by about 0.021
|
|
106
|
+
* between h = 264.05 and h = 264.10 — a visible step while dragging a hue slider, over a change
|
|
107
|
+
* no one can aim at. Taking the first boundary gives 0.1160, 0.1175, 0.1192, 0.1255, 0.1383
|
|
108
|
+
* across the same sweep: continuous, and one step less saturated at worst. It is also what the
|
|
109
|
+
* CSS Color 4 gamut-mapping algorithm does.
|
|
110
|
+
*
|
|
111
|
+
* @experimental
|
|
112
|
+
*/
|
|
113
|
+
declare function oklchToRgb({ l, c, h }: Oklch): Rgb;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hex is the notation people type, so it is the picker's front door.
|
|
117
|
+
*
|
|
118
|
+
* It lives beside the conversions rather than in the component that reads it, for the same reason
|
|
119
|
+
* they do: parsing `#3b82f6` is arithmetic on a string, a server rendering a token swatch needs it
|
|
120
|
+
* as much as an editing surface does, and pulling it into client code would put it behind the
|
|
121
|
+
* `"use client"` boundary for no benefit.
|
|
122
|
+
*
|
|
123
|
+
* @module lib/color/hex
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An sRGB colour with its alpha, which is what a hex string can carry and {@link Rgb} cannot.
|
|
128
|
+
*
|
|
129
|
+
* `alpha` is in [0, 1] like the channels, rather than 0-255, so it composes with the conversions
|
|
130
|
+
* without a second unit in play.
|
|
131
|
+
*
|
|
132
|
+
* @experimental
|
|
133
|
+
*/
|
|
134
|
+
interface Rgba extends Rgb {
|
|
135
|
+
alpha: number;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read a hex colour, or `null` if the input is not one.
|
|
139
|
+
*
|
|
140
|
+
* Returns `null` rather than throwing or substituting black: this reads what someone is part-way
|
|
141
|
+
* through typing, where "not a colour yet" is the ordinary case and neither an exception nor a
|
|
142
|
+
* silent black is a useful answer. A caller decides whether to hold the last good value, show a
|
|
143
|
+
* message, or wait.
|
|
144
|
+
*
|
|
145
|
+
* Both short forms are accepted because both are what people paste. `#abc` expands by REPEATING
|
|
146
|
+
* each digit rather than padding with zero — `#abc` is `#aabbcc`, not `#a0b0c0` — which is what CSS
|
|
147
|
+
* does and the only expansion under which `#fff` is white.
|
|
148
|
+
*
|
|
149
|
+
* @experimental
|
|
150
|
+
*/
|
|
151
|
+
declare function parseHex(input: string): Rgba | null;
|
|
152
|
+
/**
|
|
153
|
+
* Write a colour as `#rrggbb`, or `#rrggbbaa` when it is not fully opaque.
|
|
154
|
+
*
|
|
155
|
+
* The alpha pair is omitted at 1 rather than always written, because `#3b82f6ff` is the same colour
|
|
156
|
+
* and the shorter form is what someone expects to see in a field they typed `#3b82f6` into.
|
|
157
|
+
*
|
|
158
|
+
* Channels outside [0, 1] are clamped, so the result is always a valid six- or eight-digit hex
|
|
159
|
+
* string whatever a caller passes. Not for floating-point drift, which rounds to the right byte on
|
|
160
|
+
* its own: it is for a value that is wrong by a lot — a channel still in 0-255, or a computation
|
|
161
|
+
* that overshot — where the alternative is emitting `#17f00-80`, a string nothing downstream can
|
|
162
|
+
* parse and no error explains.
|
|
163
|
+
*
|
|
164
|
+
* @experimental
|
|
165
|
+
*/
|
|
166
|
+
declare function toHex(color: Rgb, alpha?: number): string;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The mapping between a pointer on a picker surface and a colour.
|
|
170
|
+
*
|
|
171
|
+
* Kept apart from the component for two reasons. It is arithmetic on numbers,
|
|
172
|
+
* so it belongs on the server-safe side with the rest of `@nextlyhq/ui/color`;
|
|
173
|
+
* and a saturation square is the one part of a picker that cannot be checked by
|
|
174
|
+
* rendering it — jsdom reports every element as zero-sized, so a component test
|
|
175
|
+
* measures nothing and passes. Measured here instead, against known corners.
|
|
176
|
+
*
|
|
177
|
+
* @module lib/color/picker-geometry
|
|
178
|
+
*/
|
|
179
|
+
/** A position on a surface, each axis in [0, 1] from the top-left. */
|
|
180
|
+
interface SurfacePoint {
|
|
181
|
+
x: number;
|
|
182
|
+
y: number;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A pointer position as a fraction of a surface.
|
|
186
|
+
*
|
|
187
|
+
* Clamped, because a drag that begins inside the surface continues to report
|
|
188
|
+
* while the pointer leaves it — which is the interaction people actually use to
|
|
189
|
+
* reach full saturation, and without clamping it produces values outside the
|
|
190
|
+
* colour space rather than the corner they are aiming at.
|
|
191
|
+
*
|
|
192
|
+
* A zero-sized surface answers the top-left rather than dividing by zero. That
|
|
193
|
+
* happens in a test environment and on the first frame before layout.
|
|
194
|
+
*
|
|
195
|
+
* @experimental
|
|
196
|
+
*/
|
|
197
|
+
declare function pointOnSurface(clientX: number, clientY: number, rect: {
|
|
198
|
+
left: number;
|
|
199
|
+
top: number;
|
|
200
|
+
width: number;
|
|
201
|
+
height: number;
|
|
202
|
+
}): SurfacePoint;
|
|
203
|
+
/**
|
|
204
|
+
* The saturation and value a point on the square stands for.
|
|
205
|
+
*
|
|
206
|
+
* Value runs UPWARD while a surface's y runs downward, so the vertical axis is
|
|
207
|
+
* inverted here. Getting that wrong produces a picker that looks correct and
|
|
208
|
+
* selects the colour vertically mirrored from the one under the cursor.
|
|
209
|
+
*
|
|
210
|
+
* @experimental
|
|
211
|
+
*/
|
|
212
|
+
declare function saturationValueAt(point: SurfacePoint): {
|
|
213
|
+
s: number;
|
|
214
|
+
v: number;
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Where the handle sits for a given saturation and value — the inverse of
|
|
218
|
+
* {@link saturationValueAt}.
|
|
219
|
+
*
|
|
220
|
+
* Derived from that function's own inversion rather than written independently,
|
|
221
|
+
* so the two cannot disagree about which way the axis runs.
|
|
222
|
+
*
|
|
223
|
+
* @experimental
|
|
224
|
+
*/
|
|
225
|
+
declare function surfacePointFor(s: number, v: number): SurfacePoint;
|
|
226
|
+
/**
|
|
227
|
+
* The hue a horizontal position stands for, in [0, 360).
|
|
228
|
+
*
|
|
229
|
+
* The upper bound is exclusive: 360 and 0 are the same hue, and returning 360
|
|
230
|
+
* lets a handle at the far end read back as a position outside the strip.
|
|
231
|
+
*
|
|
232
|
+
* @experimental
|
|
233
|
+
*/
|
|
234
|
+
declare function hueAt(fraction: number): number;
|
|
235
|
+
/** Where the hue handle sits, as a fraction of the strip. @experimental */
|
|
236
|
+
declare function huePosition(hue: number): number;
|
|
237
|
+
/**
|
|
238
|
+
* The integer step a hue occupies on a strip whose last step is `max`.
|
|
239
|
+
*
|
|
240
|
+
* Rounding is what makes this more than a multiplication. A hue just below 360
|
|
241
|
+
* — `#ff0001` is about 359.76 — rounds UP to a step past the end of the strip,
|
|
242
|
+
* and the obvious repair of wrapping it with a modulo sends it to 0: the far
|
|
243
|
+
* LEFT, the opposite end from where that colour belongs. The handle then sits
|
|
244
|
+
* on the wrong side and any keyboard adjustment starts from there. Held at the
|
|
245
|
+
* last step instead, which is where it visually belongs.
|
|
246
|
+
*
|
|
247
|
+
* @experimental
|
|
248
|
+
*/
|
|
249
|
+
declare function hueSliderValue(hue: number, max: number): number;
|
|
250
|
+
|
|
251
|
+
export { type Hsv, type Oklch, type Rgb, type Rgba, type SurfacePoint, hsvToRgb, hueAt, huePosition, hueSliderValue, normalizeHue, oklchToRgb, parseHex, pointOnSurface, rgbToHsv, rgbToOklch, saturationValueAt, surfacePointFor, toHex };
|
package/dist/color.d.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversions between the colour models an editing surface needs.
|
|
3
|
+
*
|
|
4
|
+
* ## Why each model is here
|
|
5
|
+
*
|
|
6
|
+
* - **sRGB** is what a screen displays and what `#rrggbb` encodes. Everything ends here.
|
|
7
|
+
* - **HSV** is what a colour picker is: a square of saturation against value, beside a hue
|
|
8
|
+
* strip. No other model puts those three controls in the shape people expect.
|
|
9
|
+
* - **OKLCH** is what this product's own theme is written in — every token in `theme.css` is an
|
|
10
|
+
* `oklch()` value. A picker that cannot read it cannot edit the theme.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the maths is here rather than in a library
|
|
13
|
+
*
|
|
14
|
+
* These are fixed, published transforms: the sRGB transfer function and the OKLab matrices are
|
|
15
|
+
* specified in CSS Color 4 and do not change. The package ships no runtime colour dependency, and
|
|
16
|
+
* this module keeps it that way. `culori` remains a DEV dependency used as a test oracle — the
|
|
17
|
+
* conversions here are cross-checked against it rather than trusted on their own.
|
|
18
|
+
*
|
|
19
|
+
* ## The part that is genuinely hard
|
|
20
|
+
*
|
|
21
|
+
* OKLCH describes more colours than a screen can show. Converting one that falls outside sRGB has
|
|
22
|
+
* no correct answer, only choices, and the naive choice — clamp each channel independently — is
|
|
23
|
+
* the wrong one: it shifts hue, because clipping red without clipping green changes the ratio
|
|
24
|
+
* between them. {@link oklchToRgb} reduces chroma instead, holding lightness and hue, which is the
|
|
25
|
+
* approach CSS Color 4 describes for gamut mapping.
|
|
26
|
+
*
|
|
27
|
+
* @module lib/color/convert
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* An sRGB colour with channels in [0, 1]. Alpha is carried separately.
|
|
31
|
+
*
|
|
32
|
+
* @experimental
|
|
33
|
+
*/
|
|
34
|
+
interface Rgb {
|
|
35
|
+
r: number;
|
|
36
|
+
g: number;
|
|
37
|
+
b: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Hue in [0, 360), saturation and value in [0, 1].
|
|
41
|
+
*
|
|
42
|
+
* @experimental
|
|
43
|
+
*/
|
|
44
|
+
interface Hsv {
|
|
45
|
+
h: number;
|
|
46
|
+
s: number;
|
|
47
|
+
v: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Perceptual lightness in [0, 1], chroma from 0, hue in [0, 360).
|
|
51
|
+
*
|
|
52
|
+
* @experimental
|
|
53
|
+
*/
|
|
54
|
+
interface Oklch {
|
|
55
|
+
l: number;
|
|
56
|
+
c: number;
|
|
57
|
+
h: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wrap a hue into [0, 360), so -30 and 330 are the same angle.
|
|
61
|
+
*
|
|
62
|
+
* @experimental
|
|
63
|
+
*/
|
|
64
|
+
declare function normalizeHue(hue: number): number;
|
|
65
|
+
/**
|
|
66
|
+
* HSV to sRGB.
|
|
67
|
+
* Saturation and value are clamped rather than rejected: a picker drags them, and a drag that
|
|
68
|
+
* overshoots by a rounding error should saturate rather than throw.
|
|
69
|
+
*
|
|
70
|
+
* @experimental
|
|
71
|
+
*/
|
|
72
|
+
declare function hsvToRgb({ h, s, v }: Hsv): Rgb;
|
|
73
|
+
/**
|
|
74
|
+
* sRGB to HSV.
|
|
75
|
+
* Hue is UNDEFINED for a grey and value is undefined for black, and this returns 0 for both. A
|
|
76
|
+
* picker must not take that 0 as the user's hue: it is the absence of one. Holding hue across a
|
|
77
|
+
* drag to zero saturation is the caller's job, which is why a picker keeps HSV as its state
|
|
78
|
+
* rather than deriving it from the colour each render.
|
|
79
|
+
*
|
|
80
|
+
* @experimental
|
|
81
|
+
*/
|
|
82
|
+
declare function rgbToHsv({ r, g, b }: Rgb): Hsv;
|
|
83
|
+
/**
|
|
84
|
+
* sRGB to OKLCH.
|
|
85
|
+
*
|
|
86
|
+
* @experimental
|
|
87
|
+
*/
|
|
88
|
+
declare function rgbToOklch({ r, g, b }: Rgb): Oklch;
|
|
89
|
+
/**
|
|
90
|
+
* OKLCH to sRGB, reducing chroma until the colour fits on screen.
|
|
91
|
+
* OKLCH can name colours a display cannot show. Clamping the channels independently is the
|
|
92
|
+
* obvious response and the wrong one: clipping red without clipping green changes the ratio
|
|
93
|
+
* between them, so the colour that appears is a DIFFERENT HUE from the one asked for. Lightness
|
|
94
|
+
* and hue are what a person chose; chroma is the part they will not miss, so chroma is what is
|
|
95
|
+
* given up.
|
|
96
|
+
* The search is a bisection on chroma, which converges to well under a perceptible step in the
|
|
97
|
+
* fixed number of rounds below — no loop that might not terminate.
|
|
98
|
+
*
|
|
99
|
+
* **The gamut is not always monotonic along this ray, and that is a deliberate trade.** At some
|
|
100
|
+
* lightness and hue pairs the boundary is crossed more than once: at `l = 0.2, h = 264.1` the ray
|
|
101
|
+
* is inside up to c ≈ 0.1192, outside until c ≈ 0.1368, briefly inside again to c ≈ 0.1386. A
|
|
102
|
+
* bisection therefore finds the FIRST boundary rather than the outermost reachable chroma.
|
|
103
|
+
*
|
|
104
|
+
* That is the intended behaviour rather than a limitation. Those islands span roughly a tenth of
|
|
105
|
+
* a degree of hue, so preferring the outermost value would make chroma jump by about 0.021
|
|
106
|
+
* between h = 264.05 and h = 264.10 — a visible step while dragging a hue slider, over a change
|
|
107
|
+
* no one can aim at. Taking the first boundary gives 0.1160, 0.1175, 0.1192, 0.1255, 0.1383
|
|
108
|
+
* across the same sweep: continuous, and one step less saturated at worst. It is also what the
|
|
109
|
+
* CSS Color 4 gamut-mapping algorithm does.
|
|
110
|
+
*
|
|
111
|
+
* @experimental
|
|
112
|
+
*/
|
|
113
|
+
declare function oklchToRgb({ l, c, h }: Oklch): Rgb;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hex is the notation people type, so it is the picker's front door.
|
|
117
|
+
*
|
|
118
|
+
* It lives beside the conversions rather than in the component that reads it, for the same reason
|
|
119
|
+
* they do: parsing `#3b82f6` is arithmetic on a string, a server rendering a token swatch needs it
|
|
120
|
+
* as much as an editing surface does, and pulling it into client code would put it behind the
|
|
121
|
+
* `"use client"` boundary for no benefit.
|
|
122
|
+
*
|
|
123
|
+
* @module lib/color/hex
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An sRGB colour with its alpha, which is what a hex string can carry and {@link Rgb} cannot.
|
|
128
|
+
*
|
|
129
|
+
* `alpha` is in [0, 1] like the channels, rather than 0-255, so it composes with the conversions
|
|
130
|
+
* without a second unit in play.
|
|
131
|
+
*
|
|
132
|
+
* @experimental
|
|
133
|
+
*/
|
|
134
|
+
interface Rgba extends Rgb {
|
|
135
|
+
alpha: number;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read a hex colour, or `null` if the input is not one.
|
|
139
|
+
*
|
|
140
|
+
* Returns `null` rather than throwing or substituting black: this reads what someone is part-way
|
|
141
|
+
* through typing, where "not a colour yet" is the ordinary case and neither an exception nor a
|
|
142
|
+
* silent black is a useful answer. A caller decides whether to hold the last good value, show a
|
|
143
|
+
* message, or wait.
|
|
144
|
+
*
|
|
145
|
+
* Both short forms are accepted because both are what people paste. `#abc` expands by REPEATING
|
|
146
|
+
* each digit rather than padding with zero — `#abc` is `#aabbcc`, not `#a0b0c0` — which is what CSS
|
|
147
|
+
* does and the only expansion under which `#fff` is white.
|
|
148
|
+
*
|
|
149
|
+
* @experimental
|
|
150
|
+
*/
|
|
151
|
+
declare function parseHex(input: string): Rgba | null;
|
|
152
|
+
/**
|
|
153
|
+
* Write a colour as `#rrggbb`, or `#rrggbbaa` when it is not fully opaque.
|
|
154
|
+
*
|
|
155
|
+
* The alpha pair is omitted at 1 rather than always written, because `#3b82f6ff` is the same colour
|
|
156
|
+
* and the shorter form is what someone expects to see in a field they typed `#3b82f6` into.
|
|
157
|
+
*
|
|
158
|
+
* Channels outside [0, 1] are clamped, so the result is always a valid six- or eight-digit hex
|
|
159
|
+
* string whatever a caller passes. Not for floating-point drift, which rounds to the right byte on
|
|
160
|
+
* its own: it is for a value that is wrong by a lot — a channel still in 0-255, or a computation
|
|
161
|
+
* that overshot — where the alternative is emitting `#17f00-80`, a string nothing downstream can
|
|
162
|
+
* parse and no error explains.
|
|
163
|
+
*
|
|
164
|
+
* @experimental
|
|
165
|
+
*/
|
|
166
|
+
declare function toHex(color: Rgb, alpha?: number): string;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The mapping between a pointer on a picker surface and a colour.
|
|
170
|
+
*
|
|
171
|
+
* Kept apart from the component for two reasons. It is arithmetic on numbers,
|
|
172
|
+
* so it belongs on the server-safe side with the rest of `@nextlyhq/ui/color`;
|
|
173
|
+
* and a saturation square is the one part of a picker that cannot be checked by
|
|
174
|
+
* rendering it — jsdom reports every element as zero-sized, so a component test
|
|
175
|
+
* measures nothing and passes. Measured here instead, against known corners.
|
|
176
|
+
*
|
|
177
|
+
* @module lib/color/picker-geometry
|
|
178
|
+
*/
|
|
179
|
+
/** A position on a surface, each axis in [0, 1] from the top-left. */
|
|
180
|
+
interface SurfacePoint {
|
|
181
|
+
x: number;
|
|
182
|
+
y: number;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A pointer position as a fraction of a surface.
|
|
186
|
+
*
|
|
187
|
+
* Clamped, because a drag that begins inside the surface continues to report
|
|
188
|
+
* while the pointer leaves it — which is the interaction people actually use to
|
|
189
|
+
* reach full saturation, and without clamping it produces values outside the
|
|
190
|
+
* colour space rather than the corner they are aiming at.
|
|
191
|
+
*
|
|
192
|
+
* A zero-sized surface answers the top-left rather than dividing by zero. That
|
|
193
|
+
* happens in a test environment and on the first frame before layout.
|
|
194
|
+
*
|
|
195
|
+
* @experimental
|
|
196
|
+
*/
|
|
197
|
+
declare function pointOnSurface(clientX: number, clientY: number, rect: {
|
|
198
|
+
left: number;
|
|
199
|
+
top: number;
|
|
200
|
+
width: number;
|
|
201
|
+
height: number;
|
|
202
|
+
}): SurfacePoint;
|
|
203
|
+
/**
|
|
204
|
+
* The saturation and value a point on the square stands for.
|
|
205
|
+
*
|
|
206
|
+
* Value runs UPWARD while a surface's y runs downward, so the vertical axis is
|
|
207
|
+
* inverted here. Getting that wrong produces a picker that looks correct and
|
|
208
|
+
* selects the colour vertically mirrored from the one under the cursor.
|
|
209
|
+
*
|
|
210
|
+
* @experimental
|
|
211
|
+
*/
|
|
212
|
+
declare function saturationValueAt(point: SurfacePoint): {
|
|
213
|
+
s: number;
|
|
214
|
+
v: number;
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Where the handle sits for a given saturation and value — the inverse of
|
|
218
|
+
* {@link saturationValueAt}.
|
|
219
|
+
*
|
|
220
|
+
* Derived from that function's own inversion rather than written independently,
|
|
221
|
+
* so the two cannot disagree about which way the axis runs.
|
|
222
|
+
*
|
|
223
|
+
* @experimental
|
|
224
|
+
*/
|
|
225
|
+
declare function surfacePointFor(s: number, v: number): SurfacePoint;
|
|
226
|
+
/**
|
|
227
|
+
* The hue a horizontal position stands for, in [0, 360).
|
|
228
|
+
*
|
|
229
|
+
* The upper bound is exclusive: 360 and 0 are the same hue, and returning 360
|
|
230
|
+
* lets a handle at the far end read back as a position outside the strip.
|
|
231
|
+
*
|
|
232
|
+
* @experimental
|
|
233
|
+
*/
|
|
234
|
+
declare function hueAt(fraction: number): number;
|
|
235
|
+
/** Where the hue handle sits, as a fraction of the strip. @experimental */
|
|
236
|
+
declare function huePosition(hue: number): number;
|
|
237
|
+
/**
|
|
238
|
+
* The integer step a hue occupies on a strip whose last step is `max`.
|
|
239
|
+
*
|
|
240
|
+
* Rounding is what makes this more than a multiplication. A hue just below 360
|
|
241
|
+
* — `#ff0001` is about 359.76 — rounds UP to a step past the end of the strip,
|
|
242
|
+
* and the obvious repair of wrapping it with a modulo sends it to 0: the far
|
|
243
|
+
* LEFT, the opposite end from where that colour belongs. The handle then sits
|
|
244
|
+
* on the wrong side and any keyboard adjustment starts from there. Held at the
|
|
245
|
+
* last step instead, which is where it visually belongs.
|
|
246
|
+
*
|
|
247
|
+
* @experimental
|
|
248
|
+
*/
|
|
249
|
+
declare function hueSliderValue(hue: number, max: number): number;
|
|
250
|
+
|
|
251
|
+
export { type Hsv, type Oklch, type Rgb, type Rgba, type SurfacePoint, hsvToRgb, hueAt, huePosition, hueSliderValue, normalizeHue, oklchToRgb, parseHex, pointOnSurface, rgbToHsv, rgbToOklch, saturationValueAt, surfacePointFor, toHex };
|
package/dist/color.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/lib/color/convert.ts
|
|
2
|
+
var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
3
|
+
var MAX_SRGB_CHROMA = 0.5;
|
|
4
|
+
function normalizeHue(hue) {
|
|
5
|
+
if (!Number.isFinite(hue)) return 0;
|
|
6
|
+
const wrapped = hue % 360;
|
|
7
|
+
return wrapped < 0 ? wrapped + 360 : wrapped;
|
|
8
|
+
}
|
|
9
|
+
function hsvToRgb({ h, s, v }) {
|
|
10
|
+
const hue = normalizeHue(h);
|
|
11
|
+
const sat = clamp01(s);
|
|
12
|
+
const val = clamp01(v);
|
|
13
|
+
const sector = hue / 60;
|
|
14
|
+
const chroma = val * sat;
|
|
15
|
+
const x = chroma * (1 - Math.abs(sector % 2 - 1));
|
|
16
|
+
const base = val - chroma;
|
|
17
|
+
let rgb;
|
|
18
|
+
if (sector < 1) rgb = [chroma, x, 0];
|
|
19
|
+
else if (sector < 2) rgb = [x, chroma, 0];
|
|
20
|
+
else if (sector < 3) rgb = [0, chroma, x];
|
|
21
|
+
else if (sector < 4) rgb = [0, x, chroma];
|
|
22
|
+
else if (sector < 5) rgb = [x, 0, chroma];
|
|
23
|
+
else rgb = [chroma, 0, x];
|
|
24
|
+
return { r: rgb[0] + base, g: rgb[1] + base, b: rgb[2] + base };
|
|
25
|
+
}
|
|
26
|
+
function rgbToHsv({ r, g, b }) {
|
|
27
|
+
const red = clamp01(r);
|
|
28
|
+
const green = clamp01(g);
|
|
29
|
+
const blue = clamp01(b);
|
|
30
|
+
const max = Math.max(red, green, blue);
|
|
31
|
+
const min = Math.min(red, green, blue);
|
|
32
|
+
const chroma = max - min;
|
|
33
|
+
let hue = 0;
|
|
34
|
+
if (chroma !== 0) {
|
|
35
|
+
if (max === red) hue = (green - blue) / chroma % 6;
|
|
36
|
+
else if (max === green) hue = (blue - red) / chroma + 2;
|
|
37
|
+
else hue = (red - green) / chroma + 4;
|
|
38
|
+
hue *= 60;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
h: normalizeHue(hue),
|
|
42
|
+
s: max === 0 ? 0 : chroma / max,
|
|
43
|
+
v: max
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function toLinear(channel) {
|
|
47
|
+
return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
|
48
|
+
}
|
|
49
|
+
function toGamma(channel) {
|
|
50
|
+
return channel <= 31308e-7 ? channel * 12.92 : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
|
51
|
+
}
|
|
52
|
+
function linearRgbToOklab(r, g, b) {
|
|
53
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
54
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
55
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
56
|
+
const l_ = Math.cbrt(l);
|
|
57
|
+
const m_ = Math.cbrt(m);
|
|
58
|
+
const s_ = Math.cbrt(s);
|
|
59
|
+
return [
|
|
60
|
+
0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
61
|
+
1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
62
|
+
0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
function oklabToLinearRgb(L, a, b) {
|
|
66
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
67
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
68
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
69
|
+
const l = l_ * l_ * l_;
|
|
70
|
+
const m = m_ * m_ * m_;
|
|
71
|
+
const s = s_ * s_ * s_;
|
|
72
|
+
return [
|
|
73
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
74
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
75
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
function rgbToOklch({ r, g, b }) {
|
|
79
|
+
const [L, A, B] = linearRgbToOklab(
|
|
80
|
+
toLinear(clamp01(r)),
|
|
81
|
+
toLinear(clamp01(g)),
|
|
82
|
+
toLinear(clamp01(b))
|
|
83
|
+
);
|
|
84
|
+
const chroma = Math.sqrt(A * A + B * B);
|
|
85
|
+
const hue = chroma < 1e-6 ? 0 : normalizeHue(Math.atan2(B, A) * 180 / Math.PI);
|
|
86
|
+
return { l: L, c: chroma, h: hue };
|
|
87
|
+
}
|
|
88
|
+
function inGamut([r, g, b]) {
|
|
89
|
+
const epsilon = 0;
|
|
90
|
+
return r >= -epsilon && r <= 1 + epsilon && g >= -epsilon && g <= 1 + epsilon && b >= -epsilon && b <= 1 + epsilon;
|
|
91
|
+
}
|
|
92
|
+
function oklchToRgb({ l, c, h }) {
|
|
93
|
+
const lightness = clamp01(l);
|
|
94
|
+
const hueRadians = normalizeHue(h) * Math.PI / 180;
|
|
95
|
+
const at = (chroma) => oklabToLinearRgb(
|
|
96
|
+
lightness,
|
|
97
|
+
chroma * Math.cos(hueRadians),
|
|
98
|
+
chroma * Math.sin(hueRadians)
|
|
99
|
+
);
|
|
100
|
+
const requested = Math.max(0, c);
|
|
101
|
+
let fitting = at(requested);
|
|
102
|
+
if (!inGamut(fitting)) {
|
|
103
|
+
let low = 0;
|
|
104
|
+
let high = Math.min(requested, MAX_SRGB_CHROMA);
|
|
105
|
+
for (let i = 0; i < 20; i++) {
|
|
106
|
+
const mid = (low + high) / 2;
|
|
107
|
+
if (inGamut(at(mid))) low = mid;
|
|
108
|
+
else high = mid;
|
|
109
|
+
}
|
|
110
|
+
fitting = at(low);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
r: clamp01(toGamma(fitting[0])),
|
|
114
|
+
g: clamp01(toGamma(fitting[1])),
|
|
115
|
+
b: clamp01(toGamma(fitting[2]))
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/lib/color/hex.ts
|
|
120
|
+
var HEX = /^#?(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
121
|
+
var clamp012 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
122
|
+
function pair(channel) {
|
|
123
|
+
const value = Number.isFinite(channel) ? clamp012(channel) : 0;
|
|
124
|
+
return Math.round(value * 255).toString(16).padStart(2, "0");
|
|
125
|
+
}
|
|
126
|
+
function parseHex(input) {
|
|
127
|
+
const text = input.trim();
|
|
128
|
+
if (!HEX.test(text)) return null;
|
|
129
|
+
const digits = text.replace("#", "");
|
|
130
|
+
const short = digits.length < 6;
|
|
131
|
+
const size = short ? 1 : 2;
|
|
132
|
+
const channel = (index) => {
|
|
133
|
+
const slice = digits.slice(index * size, index * size + size);
|
|
134
|
+
return parseInt(short ? slice + slice : slice, 16) / 255;
|
|
135
|
+
};
|
|
136
|
+
const hasAlpha = digits.length === 4 || digits.length === 8;
|
|
137
|
+
return {
|
|
138
|
+
r: channel(0),
|
|
139
|
+
g: channel(1),
|
|
140
|
+
b: channel(2),
|
|
141
|
+
alpha: hasAlpha ? channel(3) : 1
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function toHex(color, alpha = 1) {
|
|
145
|
+
const opacity = Number.isFinite(alpha) ? clamp012(alpha) : 1;
|
|
146
|
+
const opaque = `#${pair(color.r)}${pair(color.g)}${pair(color.b)}`;
|
|
147
|
+
return opacity === 1 ? opaque : `${opaque}${pair(opacity)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/lib/color/picker-geometry.ts
|
|
151
|
+
var clamp013 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
152
|
+
function pointOnSurface(clientX, clientY, rect) {
|
|
153
|
+
return {
|
|
154
|
+
x: rect.width === 0 ? 0 : clamp013((clientX - rect.left) / rect.width),
|
|
155
|
+
y: rect.height === 0 ? 0 : clamp013((clientY - rect.top) / rect.height)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function saturationValueAt(point) {
|
|
159
|
+
return { s: clamp013(point.x), v: 1 - clamp013(point.y) };
|
|
160
|
+
}
|
|
161
|
+
function surfacePointFor(s, v) {
|
|
162
|
+
return { x: clamp013(s), y: 1 - clamp013(v) };
|
|
163
|
+
}
|
|
164
|
+
function hueAt(fraction) {
|
|
165
|
+
const hue = clamp013(fraction) * 360;
|
|
166
|
+
return hue >= 360 ? 0 : hue;
|
|
167
|
+
}
|
|
168
|
+
function huePosition(hue) {
|
|
169
|
+
const wrapped = (hue % 360 + 360) % 360;
|
|
170
|
+
return wrapped / 360;
|
|
171
|
+
}
|
|
172
|
+
function hueSliderValue(hue, max) {
|
|
173
|
+
const step = Math.round(huePosition(hue) * (max + 1));
|
|
174
|
+
return step > max ? max : step;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { hsvToRgb, hueAt, huePosition, hueSliderValue, normalizeHue, oklchToRgb, parseHex, pointOnSurface, rgbToHsv, rgbToOklch, saturationValueAt, surfacePointFor, toHex };
|
|
178
|
+
//# sourceMappingURL=color.mjs.map
|
|
179
|
+
//# sourceMappingURL=color.mjs.map
|