@nextlyhq/ui 0.0.2-alpha.6 → 0.0.2-alpha.60

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 CHANGED
@@ -31,56 +31,134 @@ pnpm add react react-dom lucide-react
31
31
 
32
32
  ## Setup
33
33
 
34
- The components ship as Tailwind CSS 4 token consumers. They reference HSL CSS variables (`--background`, `--primary`, `--border`, etc.) which your project must define.
34
+ The package ships three CSS entry points. Pick one:
35
35
 
36
- **Tailwind v4 (recommended)**
36
+ **Zero config — pre-compiled bundle**
37
37
 
38
- In your global CSS, define the design tokens with `@theme` and import the components:
38
+ Import the pre-built stylesheet once at your root. It bundles Tailwind, every design token
39
+ (on `:root`, flipped under `.dark`), and the base reset, so components render fully styled
40
+ with no build wiring:
39
41
 
40
- ```css
41
- /* app/globals.css */
42
- @import "tailwindcss";
42
+ ```tsx
43
+ import "@nextlyhq/ui/styles.css";
44
+ import { Button } from "@nextlyhq/ui";
43
45
 
44
- @theme {
45
- --color-background: hsl(0 0% 100%);
46
- --color-foreground: hsl(222 47% 11%);
47
- --color-primary: hsl(221 83% 53%);
48
- --color-primary-foreground: hsl(0 0% 100%);
49
- /* ...the rest. See `uiPreset` for the full token contract. */
50
- }
46
+ <Button variant="outline">Click me</Button>;
51
47
  ```
52
48
 
53
- The exported `uiPreset` is the reference contract: it lists every token name the components expect. Use it to write the matching `@theme` block (or import it directly if you are still on Tailwind v3).
49
+ **Bring your own Tailwind v4 build**
54
50
 
55
- ```tsx
56
- import { Button } from "@nextlyhq/ui";
51
+ If you already run Tailwind v4, import just the tokens and let your pipeline compile the
52
+ utilities the components use:
57
53
 
58
- <Button variant="outline">Click me</Button>;
54
+ ```css
55
+ /* app/globals.css */
56
+ @import "tailwindcss";
57
+ @import "@nextlyhq/ui/theme.css";
58
+ @source "../node_modules/@nextlyhq/ui/dist";
59
59
  ```
60
60
 
61
+ `theme.css` defines the tokens as complete OKLCH color values (`--nx-background`, `--nx-primary`,
62
+ `--nx-border`, …) with `@theme inline` mappings and the dark-mode overrides. Reference tokens
63
+ directly (`var(--nx-primary)`) — never wrap them in `hsl()`. `uiPreset` remains available
64
+ as a Tailwind v3 preset from `@nextlyhq/ui/tailwind-preset`.
65
+
66
+ > Inside a Nextly admin plugin you need neither import — the admin already provides the
67
+ > tokens. See the [**Plugin UI authoring guide**](./docs/plugin-ui-authoring.md).
68
+
61
69
  ## Components
62
70
 
63
71
  **Buttons and inputs:** `Button`, `Input`, `Textarea`, `Label`
64
72
  **Display:** `Badge`, `Card`, `Alert`, `Separator`, `Skeleton`, `Progress`
65
73
  **Toggles:** `Checkbox`, `RadioGroup`, `Switch`, `Collapsible`
66
74
  **Layout and disclosure:** `Accordion`, `Avatar`, `Tabs`, `Tooltip`, `Popover`
75
+ **Resizable regions:** `ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle`
76
+ **Hierarchies:** `TreeView` (virtualized, keyboard-operable)
67
77
  **Overlays:** `Dialog`, `AlertDialog`, `Sheet`
68
- **Menus and command palette:** `DropdownMenu`, `Select`, `Command`
78
+ **Menus and command palette:** `DropdownMenu`, `ContextMenu`, `Select`, `Command`
69
79
  **Feedback:** `Spinner`, `Toaster` (with `toast()` helper)
70
80
  **Tables:** `Table` primitives, `ResponsiveTable`, `TableSearch`, `TablePagination`, `TableSkeleton`, `TableEmpty`, `TableError`, `TableLoading`
71
81
  **Providers:** `PortalProvider`, `usePortalContainer`
72
- **Utilities:** `cn`, `uiPreset`
82
+
83
+ > Which of these carry a stability guarantee is recorded in
84
+ > [STABILITY.md](https://github.com/nextlyhq/nextly/blob/main/packages/ui/STABILITY.md). A component becomes `@public` once a first-party
85
+ > plugin depends on it; everything else is `@experimental` and may change in any
86
+ > release.
87
+
88
+ **Utilities:** `cn` (from `@nextlyhq/ui/utils`), `uiPreset` (from `@nextlyhq/ui/tailwind-preset`)
89
+
90
+ > Both ship from their own subpaths rather than the root: the root bundle is
91
+ > published with `"use client"`, and neither of these contains a React runtime,
92
+ > so a server component or a Tailwind config can import them safely.
93
+
94
+ ## Stylesheets
95
+
96
+ | Import | Use when |
97
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `@nextlyhq/ui/styles.css` | The app is yours end to end. Styles the whole document, Tailwind preflight included. |
99
+ | `@nextlyhq/ui/styles.scoped.css` | Dropping a few components into an existing app. Every rule is confined to `.nextly-ui`, so the rest of the page keeps its own styles. |
100
+ | `@nextlyhq/ui/theme.css` | You compile Tailwind yourself and want the token contract only. |
101
+
102
+ The scoped sheet needs a wrapper element, and dark mode goes on the same element:
103
+
104
+ ```tsx
105
+ import "@nextlyhq/ui/styles.scoped.css";
106
+
107
+ <div className="nextly-ui">
108
+ <Button>Save</Button>
109
+ </div>;
110
+ ```
111
+
112
+ It keeps preflight rather than dropping it — these components are designed against a
113
+ normalised baseline — but confines it to the wrapper, so your headings, lists and form
114
+ controls outside it are untouched. Three things CSS resolves globally are namespaced
115
+ along with the selectors, so the sheet cannot reach outside the wrapper through them
116
+ either: animation names (it will not displace a `spin` or `fade-in` you define),
117
+ Tailwind's internal `--tw-*` registrations, and the ancestor classes `dark:` and
118
+ `group-*:` variants look for. Dark mode is therefore driven by the wrapper, not by a
119
+ `dark` class higher up your page.
120
+
121
+ ### Overlays need a portal container
122
+
123
+ AlertDialog, Command, ContextMenu, Dialog, DropdownMenu, Popover, Select, Sheet and
124
+ Tooltip render
125
+ their overlay through a portal, which defaults to `document.body` — outside the wrapper,
126
+ where the scoped rules and tokens do not reach. Triggers would look right and the menus
127
+ they open would not. Point them back inside with `PortalProvider`:
128
+
129
+ ```tsx
130
+ import { useState } from "react";
131
+ import { PortalProvider } from "@nextlyhq/ui";
132
+ import "@nextlyhq/ui/styles.scoped.css";
133
+
134
+ function Kit({ children }: { children: React.ReactNode }) {
135
+ const [container, setContainer] = useState<HTMLElement | null>(null);
136
+
137
+ return (
138
+ <div className="nextly-ui" ref={setContainer}>
139
+ <PortalProvider container={container}>{children}</PortalProvider>
140
+ </div>
141
+ );
142
+ }
143
+ ```
144
+
145
+ A callback ref rather than `useRef` because the container has to be a state value: on the
146
+ first render it is still `null`, and the overlays need a re-render once the element exists.
147
+
148
+ This does not apply to `styles.css`, where the rules are document-wide and
149
+ `document.body` is already covered.
73
150
 
74
151
  ## Compatibility
75
152
 
76
- | Tool | Version |
77
- | -------------- | ---------------------------------------------------------------- |
78
- | React | 18 or 19 |
79
- | Tailwind CSS | 4+ (the `uiPreset` JS export also works as a Tailwind v3 preset) |
80
- | `lucide-react` | 0.400+ |
153
+ | Tool | Version |
154
+ | -------------- | ---------------------------------------------------------------------- |
155
+ | React | 18 or 19 |
156
+ | Tailwind CSS | 4+ (`@nextlyhq/ui/tailwind-preset` also works as a Tailwind v3 preset) |
157
+ | `lucide-react` | 0.400+ |
81
158
 
82
159
  ## Documentation
83
160
 
161
+ - [**Plugin UI authoring guide**](./docs/plugin-ui-authoring.md): the token contract, dark mode, container queries, and the design lint guard
84
162
  - [**Admin customization**](https://nextlyhq.com/docs/admin/customization): theming, branding, and custom field UIs
85
163
 
86
164
  ## Related packages
package/dist/color.cjs ADDED
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ // src/lib/color/convert.ts
4
+ var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
5
+ var MAX_SRGB_CHROMA = 0.5;
6
+ function normalizeHue(hue) {
7
+ if (!Number.isFinite(hue)) return 0;
8
+ const wrapped = hue % 360;
9
+ return wrapped < 0 ? wrapped + 360 : wrapped;
10
+ }
11
+ function hsvToRgb({ h, s, v }) {
12
+ const hue = normalizeHue(h);
13
+ const sat = clamp01(s);
14
+ const val = clamp01(v);
15
+ const sector = hue / 60;
16
+ const chroma = val * sat;
17
+ const x = chroma * (1 - Math.abs(sector % 2 - 1));
18
+ const base = val - chroma;
19
+ let rgb;
20
+ if (sector < 1) rgb = [chroma, x, 0];
21
+ else if (sector < 2) rgb = [x, chroma, 0];
22
+ else if (sector < 3) rgb = [0, chroma, x];
23
+ else if (sector < 4) rgb = [0, x, chroma];
24
+ else if (sector < 5) rgb = [x, 0, chroma];
25
+ else rgb = [chroma, 0, x];
26
+ return { r: rgb[0] + base, g: rgb[1] + base, b: rgb[2] + base };
27
+ }
28
+ function rgbToHsv({ r, g, b }) {
29
+ const red = clamp01(r);
30
+ const green = clamp01(g);
31
+ const blue = clamp01(b);
32
+ const max = Math.max(red, green, blue);
33
+ const min = Math.min(red, green, blue);
34
+ const chroma = max - min;
35
+ let hue = 0;
36
+ if (chroma !== 0) {
37
+ if (max === red) hue = (green - blue) / chroma % 6;
38
+ else if (max === green) hue = (blue - red) / chroma + 2;
39
+ else hue = (red - green) / chroma + 4;
40
+ hue *= 60;
41
+ }
42
+ return {
43
+ h: normalizeHue(hue),
44
+ s: max === 0 ? 0 : chroma / max,
45
+ v: max
46
+ };
47
+ }
48
+ function toLinear(channel) {
49
+ return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
50
+ }
51
+ function toGamma(channel) {
52
+ return channel <= 31308e-7 ? channel * 12.92 : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
53
+ }
54
+ function linearRgbToOklab(r, g, b) {
55
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
56
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
57
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
58
+ const l_ = Math.cbrt(l);
59
+ const m_ = Math.cbrt(m);
60
+ const s_ = Math.cbrt(s);
61
+ return [
62
+ 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
63
+ 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
64
+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
65
+ ];
66
+ }
67
+ function oklabToLinearRgb(L, a, b) {
68
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
69
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
70
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
71
+ const l = l_ * l_ * l_;
72
+ const m = m_ * m_ * m_;
73
+ const s = s_ * s_ * s_;
74
+ return [
75
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
76
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
77
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
78
+ ];
79
+ }
80
+ function rgbToOklch({ r, g, b }) {
81
+ const [L, A, B] = linearRgbToOklab(
82
+ toLinear(clamp01(r)),
83
+ toLinear(clamp01(g)),
84
+ toLinear(clamp01(b))
85
+ );
86
+ const chroma = Math.sqrt(A * A + B * B);
87
+ const hue = chroma < 1e-6 ? 0 : normalizeHue(Math.atan2(B, A) * 180 / Math.PI);
88
+ return { l: L, c: chroma, h: hue };
89
+ }
90
+ function inGamut([r, g, b]) {
91
+ const epsilon = 0;
92
+ return r >= -epsilon && r <= 1 + epsilon && g >= -epsilon && g <= 1 + epsilon && b >= -epsilon && b <= 1 + epsilon;
93
+ }
94
+ function oklchToRgb({ l, c, h }) {
95
+ const lightness = clamp01(l);
96
+ const hueRadians = normalizeHue(h) * Math.PI / 180;
97
+ const at = (chroma) => oklabToLinearRgb(
98
+ lightness,
99
+ chroma * Math.cos(hueRadians),
100
+ chroma * Math.sin(hueRadians)
101
+ );
102
+ const requested = Math.max(0, c);
103
+ let fitting = at(requested);
104
+ if (!inGamut(fitting)) {
105
+ let low = 0;
106
+ let high = Math.min(requested, MAX_SRGB_CHROMA);
107
+ for (let i = 0; i < 20; i++) {
108
+ const mid = (low + high) / 2;
109
+ if (inGamut(at(mid))) low = mid;
110
+ else high = mid;
111
+ }
112
+ fitting = at(low);
113
+ }
114
+ return {
115
+ r: clamp01(toGamma(fitting[0])),
116
+ g: clamp01(toGamma(fitting[1])),
117
+ b: clamp01(toGamma(fitting[2]))
118
+ };
119
+ }
120
+
121
+ // src/lib/color/hex.ts
122
+ var HEX = /^#?(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
123
+ var clamp012 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
124
+ function pair(channel) {
125
+ const value = Number.isFinite(channel) ? clamp012(channel) : 0;
126
+ return Math.round(value * 255).toString(16).padStart(2, "0");
127
+ }
128
+ function parseHex(input) {
129
+ const text = input.trim();
130
+ if (!HEX.test(text)) return null;
131
+ const digits = text.replace("#", "");
132
+ const short = digits.length < 6;
133
+ const size = short ? 1 : 2;
134
+ const channel = (index) => {
135
+ const slice = digits.slice(index * size, index * size + size);
136
+ return parseInt(short ? slice + slice : slice, 16) / 255;
137
+ };
138
+ const hasAlpha = digits.length === 4 || digits.length === 8;
139
+ return {
140
+ r: channel(0),
141
+ g: channel(1),
142
+ b: channel(2),
143
+ alpha: hasAlpha ? channel(3) : 1
144
+ };
145
+ }
146
+ function toHex(color, alpha = 1) {
147
+ const opacity = Number.isFinite(alpha) ? clamp012(alpha) : 1;
148
+ const opaque = `#${pair(color.r)}${pair(color.g)}${pair(color.b)}`;
149
+ return opacity === 1 ? opaque : `${opaque}${pair(opacity)}`;
150
+ }
151
+
152
+ // src/lib/color/picker-geometry.ts
153
+ var clamp013 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
154
+ function pointOnSurface(clientX, clientY, rect) {
155
+ return {
156
+ x: rect.width === 0 ? 0 : clamp013((clientX - rect.left) / rect.width),
157
+ y: rect.height === 0 ? 0 : clamp013((clientY - rect.top) / rect.height)
158
+ };
159
+ }
160
+ function saturationValueAt(point) {
161
+ return { s: clamp013(point.x), v: 1 - clamp013(point.y) };
162
+ }
163
+ function surfacePointFor(s, v) {
164
+ return { x: clamp013(s), y: 1 - clamp013(v) };
165
+ }
166
+ function hueAt(fraction) {
167
+ const hue = clamp013(fraction) * 360;
168
+ return hue >= 360 ? 0 : hue;
169
+ }
170
+ function huePosition(hue) {
171
+ const wrapped = (hue % 360 + 360) % 360;
172
+ return wrapped / 360;
173
+ }
174
+ function hueSliderValue(hue, max) {
175
+ const step = Math.round(huePosition(hue) * (max + 1));
176
+ return step > max ? max : step;
177
+ }
178
+
179
+ exports.hsvToRgb = hsvToRgb;
180
+ exports.hueAt = hueAt;
181
+ exports.huePosition = huePosition;
182
+ exports.hueSliderValue = hueSliderValue;
183
+ exports.normalizeHue = normalizeHue;
184
+ exports.oklchToRgb = oklchToRgb;
185
+ exports.parseHex = parseHex;
186
+ exports.pointOnSurface = pointOnSurface;
187
+ exports.rgbToHsv = rgbToHsv;
188
+ exports.rgbToOklch = rgbToOklch;
189
+ exports.saturationValueAt = saturationValueAt;
190
+ exports.surfacePointFor = surfacePointFor;
191
+ exports.toHex = toHex;
192
+ //# sourceMappingURL=color.cjs.map
193
+ //# sourceMappingURL=color.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/color/convert.ts","../src/lib/color/hex.ts","../src/lib/color/picker-geometry.ts"],"names":["clamp01"],"mappings":"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","file":"color.cjs","sourcesContent":["/**\n * Conversions between the colour models an editing surface needs.\n *\n * ## Why each model is here\n *\n * - **sRGB** is what a screen displays and what `#rrggbb` encodes. Everything ends here.\n * - **HSV** is what a colour picker is: a square of saturation against value, beside a hue\n * strip. No other model puts those three controls in the shape people expect.\n * - **OKLCH** is what this product's own theme is written in — every token in `theme.css` is an\n * `oklch()` value. A picker that cannot read it cannot edit the theme.\n *\n * ## Why the maths is here rather than in a library\n *\n * These are fixed, published transforms: the sRGB transfer function and the OKLab matrices are\n * specified in CSS Color 4 and do not change. The package ships no runtime colour dependency, and\n * this module keeps it that way. `culori` remains a DEV dependency used as a test oracle — the\n * conversions here are cross-checked against it rather than trusted on their own.\n *\n * ## The part that is genuinely hard\n *\n * OKLCH describes more colours than a screen can show. Converting one that falls outside sRGB has\n * no correct answer, only choices, and the naive choice — clamp each channel independently — is\n * the wrong one: it shifts hue, because clipping red without clipping green changes the ratio\n * between them. {@link oklchToRgb} reduces chroma instead, holding lightness and hue, which is the\n * approach CSS Color 4 describes for gamut mapping.\n *\n * @module lib/color/convert\n */\n\n/**\n * An sRGB colour with channels in [0, 1]. Alpha is carried separately.\n *\n * @experimental\n */\nexport interface Rgb {\n r: number;\n g: number;\n b: number;\n}\n\n/**\n * Hue in [0, 360), saturation and value in [0, 1].\n *\n * @experimental\n */\nexport interface Hsv {\n h: number;\n s: number;\n v: number;\n}\n\n/**\n * Perceptual lightness in [0, 1], chroma from 0, hue in [0, 360).\n *\n * @experimental\n */\nexport interface Oklch {\n l: number;\n c: number;\n h: number;\n}\n\nconst clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/**\n * A chroma no displayable sRGB colour exceeds.\n *\n * The most saturated sRGB primaries sit near 0.37 in OKLCH; this leaves headroom above that and\n * gives the gamut search a fixed bracket, so its error is absolute rather than proportional to\n * whatever the caller asked for.\n */\nconst MAX_SRGB_CHROMA = 0.5;\n\n/**\n * Wrap a hue into [0, 360), so -30 and 330 are the same angle.\n *\n * @experimental\n */\nexport function normalizeHue(hue: number): number {\n if (!Number.isFinite(hue)) return 0;\n const wrapped = hue % 360;\n return wrapped < 0 ? wrapped + 360 : wrapped;\n}\n\n/**\n * HSV to sRGB.\n * Saturation and value are clamped rather than rejected: a picker drags them, and a drag that\n * overshoots by a rounding error should saturate rather than throw.\n *\n * @experimental\n */\nexport function hsvToRgb({ h, s, v }: Hsv): Rgb {\n const hue = normalizeHue(h);\n const sat = clamp01(s);\n const val = clamp01(v);\n\n const sector = hue / 60;\n const chroma = val * sat;\n // The second-largest component, which falls as the hue moves away from a primary.\n const x = chroma * (1 - Math.abs((sector % 2) - 1));\n const base = val - chroma;\n\n let rgb: [number, number, number];\n if (sector < 1) rgb = [chroma, x, 0];\n else if (sector < 2) rgb = [x, chroma, 0];\n else if (sector < 3) rgb = [0, chroma, x];\n else if (sector < 4) rgb = [0, x, chroma];\n else if (sector < 5) rgb = [x, 0, chroma];\n else rgb = [chroma, 0, x];\n\n return { r: rgb[0] + base, g: rgb[1] + base, b: rgb[2] + base };\n}\n\n/**\n * sRGB to HSV.\n * Hue is UNDEFINED for a grey and value is undefined for black, and this returns 0 for both. A\n * picker must not take that 0 as the user's hue: it is the absence of one. Holding hue across a\n * drag to zero saturation is the caller's job, which is why a picker keeps HSV as its state\n * rather than deriving it from the colour each render.\n *\n * @experimental\n */\nexport function rgbToHsv({ r, g, b }: Rgb): Hsv {\n const red = clamp01(r);\n const green = clamp01(g);\n const blue = clamp01(b);\n\n const max = Math.max(red, green, blue);\n const min = Math.min(red, green, blue);\n const chroma = max - min;\n\n let hue = 0;\n if (chroma !== 0) {\n if (max === red) hue = ((green - blue) / chroma) % 6;\n else if (max === green) hue = (blue - red) / chroma + 2;\n else hue = (red - green) / chroma + 4;\n hue *= 60;\n }\n\n return {\n h: normalizeHue(hue),\n s: max === 0 ? 0 : chroma / max,\n v: max,\n };\n}\n\n/** The sRGB transfer function, gamma-encoded to linear light. */\nfunction toLinear(channel: number): number {\n return channel <= 0.04045\n ? channel / 12.92\n : Math.pow((channel + 0.055) / 1.055, 2.4);\n}\n\n/** The inverse transfer function, linear light back to gamma-encoded. */\nfunction toGamma(channel: number): number {\n return channel <= 0.0031308\n ? channel * 12.92\n : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;\n}\n\n/**\n * Linear sRGB to OKLab.\n *\n * The two matrices and the cube root between them are the definition of OKLab, given in CSS\n * Color 4. They are transcribed rather than derived.\n */\nfunction linearRgbToOklab(\n r: number,\n g: number,\n b: number\n): [number, number, number] {\n const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;\n const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;\n const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;\n\n const l_ = Math.cbrt(l);\n const m_ = Math.cbrt(m);\n const s_ = Math.cbrt(s);\n\n return [\n 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,\n 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,\n 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,\n ];\n}\n\n/** OKLab back to linear sRGB, the inverse of {@link linearRgbToOklab}. */\nfunction oklabToLinearRgb(\n L: number,\n a: number,\n b: number\n): [number, number, number] {\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n\n const l = l_ * l_ * l_;\n const m = m_ * m_ * m_;\n const s = s_ * s_ * s_;\n\n return [\n 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,\n ];\n}\n\n/**\n * sRGB to OKLCH.\n *\n * @experimental\n */\nexport function rgbToOklch({ r, g, b }: Rgb): Oklch {\n const [L, A, B] = linearRgbToOklab(\n toLinear(clamp01(r)),\n toLinear(clamp01(g)),\n toLinear(clamp01(b))\n );\n const chroma = Math.sqrt(A * A + B * B);\n // Below this the hue angle is numerical noise rather than a colour, so it is reported as 0\n // instead of whatever direction the rounding happened to point.\n const hue =\n chroma < 1e-6 ? 0 : normalizeHue((Math.atan2(B, A) * 180) / Math.PI);\n return { l: L, c: chroma, h: hue };\n}\n\n/** Whether every channel of a linear triple lies within the displayable range. */\nfunction inGamut([r, g, b]: [number, number, number]): boolean {\n // No tolerance at all: the point must be GENUINELY inside the gamut.\n //\n // Any fixed tolerance is eventually comparable to the channel values themselves, because those\n // shrink without bound as lightness falls — 1e-6 broke the hue at l=0.01, and 1e-12 still broke\n // it at l=0.0001. A tolerance exists to absorb the cube roots' error for a colour sitting\n // exactly ON the boundary, and the cost of not absorbing it is that such a colour comes back\n // with its chroma reduced by less than one part in a million, which nothing can display. The\n // cost of absorbing it is a channel that must then be clamped, and clamping is precisely what\n // moves the hue this function promises to keep.\n const epsilon = 0;\n return (\n r >= -epsilon &&\n r <= 1 + epsilon &&\n g >= -epsilon &&\n g <= 1 + epsilon &&\n b >= -epsilon &&\n b <= 1 + epsilon\n );\n}\n\n/**\n * OKLCH to sRGB, reducing chroma until the colour fits on screen.\n * OKLCH can name colours a display cannot show. Clamping the channels independently is the\n * obvious response and the wrong one: clipping red without clipping green changes the ratio\n * between them, so the colour that appears is a DIFFERENT HUE from the one asked for. Lightness\n * and hue are what a person chose; chroma is the part they will not miss, so chroma is what is\n * given up.\n * The search is a bisection on chroma, which converges to well under a perceptible step in the\n * fixed number of rounds below — no loop that might not terminate.\n *\n * **The gamut is not always monotonic along this ray, and that is a deliberate trade.** At some\n * lightness and hue pairs the boundary is crossed more than once: at `l = 0.2, h = 264.1` the ray\n * is inside up to c ≈ 0.1192, outside until c ≈ 0.1368, briefly inside again to c ≈ 0.1386. A\n * bisection therefore finds the FIRST boundary rather than the outermost reachable chroma.\n *\n * That is the intended behaviour rather than a limitation. Those islands span roughly a tenth of\n * a degree of hue, so preferring the outermost value would make chroma jump by about 0.021\n * between h = 264.05 and h = 264.10 — a visible step while dragging a hue slider, over a change\n * no one can aim at. Taking the first boundary gives 0.1160, 0.1175, 0.1192, 0.1255, 0.1383\n * across the same sweep: continuous, and one step less saturated at worst. It is also what the\n * CSS Color 4 gamut-mapping algorithm does.\n *\n * @experimental\n */\nexport function oklchToRgb({ l, c, h }: Oklch): Rgb {\n const lightness = clamp01(l);\n const hueRadians = (normalizeHue(h) * Math.PI) / 180;\n\n const at = (chroma: number): [number, number, number] =>\n oklabToLinearRgb(\n lightness,\n chroma * Math.cos(hueRadians),\n chroma * Math.sin(hueRadians)\n );\n\n const requested = Math.max(0, c);\n let fitting = at(requested);\n\n if (!inGamut(fitting)) {\n let low = 0;\n // Bracketed against an ABSOLUTE ceiling rather than the requested chroma. Halving an\n // unbounded input a fixed number of times converges on a fraction OF THAT INPUT, so a\n // sufficiently large chroma leaves the interval far above the gamut and the search returns\n // zero — discarding the hue entirely and answering grey for a colour that has one. No sRGB\n // colour exceeds a chroma of roughly 0.37, so this ceiling cannot exclude a reachable one.\n let high = Math.min(requested, MAX_SRGB_CHROMA);\n // Halvings of a fixed interval, so the remaining error is absolute: 0.5 / 2^20 is far below\n // anything a screen or an eye resolves.\n for (let i = 0; i < 20; i++) {\n const mid = (low + high) / 2;\n if (inGamut(at(mid))) low = mid;\n else high = mid;\n }\n fitting = at(low);\n }\n\n return {\n r: clamp01(toGamma(fitting[0])),\n g: clamp01(toGamma(fitting[1])),\n b: clamp01(toGamma(fitting[2])),\n };\n}\n","/**\n * Hex is the notation people type, so it is the picker's front door.\n *\n * It lives beside the conversions rather than in the component that reads it, for the same reason\n * they do: parsing `#3b82f6` is arithmetic on a string, a server rendering a token swatch needs it\n * as much as an editing surface does, and pulling it into client code would put it behind the\n * `\"use client\"` boundary for no benefit.\n *\n * @module lib/color/hex\n */\n\nimport type { Rgb } from \"./convert\";\n\n/**\n * An sRGB colour with its alpha, which is what a hex string can carry and {@link Rgb} cannot.\n *\n * `alpha` is in [0, 1] like the channels, rather than 0-255, so it composes with the conversions\n * without a second unit in play.\n *\n * @experimental\n */\nexport interface Rgba extends Rgb {\n alpha: number;\n}\n\n/** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`, with the hash optional and case ignored. */\nconst HEX = /^#?(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\n\nconst clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/** A channel byte as a two-digit lowercase pair. */\nfunction pair(channel: number): string {\n // A non-finite channel becomes 0 rather than passing through. `clamp01(NaN)` is NaN, because\n // both of its comparisons are false, and formatting that yields the three characters \"NaN\" —\n // producing `#NaN0000`, which is exactly the string this module documents itself as never\n // returning.\n const value = Number.isFinite(channel) ? clamp01(channel) : 0;\n return Math.round(value * 255)\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Read a hex colour, or `null` if the input is not one.\n *\n * Returns `null` rather than throwing or substituting black: this reads what someone is part-way\n * through typing, where \"not a colour yet\" is the ordinary case and neither an exception nor a\n * silent black is a useful answer. A caller decides whether to hold the last good value, show a\n * message, or wait.\n *\n * Both short forms are accepted because both are what people paste. `#abc` expands by REPEATING\n * each digit rather than padding with zero — `#abc` is `#aabbcc`, not `#a0b0c0` — which is what CSS\n * does and the only expansion under which `#fff` is white.\n *\n * @experimental\n */\nexport function parseHex(input: string): Rgba | null {\n const text = input.trim();\n if (!HEX.test(text)) return null;\n\n const digits = text.replace(\"#\", \"\");\n const short = digits.length < 6;\n const size = short ? 1 : 2;\n const channel = (index: number): number => {\n const slice = digits.slice(index * size, index * size + size);\n return parseInt(short ? slice + slice : slice, 16) / 255;\n };\n\n const hasAlpha = digits.length === 4 || digits.length === 8;\n return {\n r: channel(0),\n g: channel(1),\n b: channel(2),\n alpha: hasAlpha ? channel(3) : 1,\n };\n}\n\n/**\n * Write a colour as `#rrggbb`, or `#rrggbbaa` when it is not fully opaque.\n *\n * The alpha pair is omitted at 1 rather than always written, because `#3b82f6ff` is the same colour\n * and the shorter form is what someone expects to see in a field they typed `#3b82f6` into.\n *\n * Channels outside [0, 1] are clamped, so the result is always a valid six- or eight-digit hex\n * string whatever a caller passes. Not for floating-point drift, which rounds to the right byte on\n * its own: it is for a value that is wrong by a lot — a channel still in 0-255, or a computation\n * that overshot — where the alternative is emitting `#17f00-80`, a string nothing downstream can\n * parse and no error explains.\n *\n * @experimental\n */\nexport function toHex(color: Rgb, alpha = 1): string {\n // A non-finite alpha falls back to 1, not to 0 as a channel does. Both are \"nothing was\n // specified\", and for alpha that is this parameter's own default — where treating it as 0 would\n // turn a colour invisible on a stray NaN, silently and with no way to tell from the output.\n const opacity = Number.isFinite(alpha) ? clamp01(alpha) : 1;\n const opaque = `#${pair(color.r)}${pair(color.g)}${pair(color.b)}`;\n return opacity === 1 ? opaque : `${opaque}${pair(opacity)}`;\n}\n","/**\n * The mapping between a pointer on a picker surface and a colour.\n *\n * Kept apart from the component for two reasons. It is arithmetic on numbers,\n * so it belongs on the server-safe side with the rest of `@nextlyhq/ui/color`;\n * and a saturation square is the one part of a picker that cannot be checked by\n * rendering it — jsdom reports every element as zero-sized, so a component test\n * measures nothing and passes. Measured here instead, against known corners.\n *\n * @module lib/color/picker-geometry\n */\n\n/** A position on a surface, each axis in [0, 1] from the top-left. */\nexport interface SurfacePoint {\n x: number;\n y: number;\n}\n\nconst clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/**\n * A pointer position as a fraction of a surface.\n *\n * Clamped, because a drag that begins inside the surface continues to report\n * while the pointer leaves it — which is the interaction people actually use to\n * reach full saturation, and without clamping it produces values outside the\n * colour space rather than the corner they are aiming at.\n *\n * A zero-sized surface answers the top-left rather than dividing by zero. That\n * happens in a test environment and on the first frame before layout.\n *\n * @experimental\n */\nexport function pointOnSurface(\n clientX: number,\n clientY: number,\n rect: { left: number; top: number; width: number; height: number }\n): SurfacePoint {\n return {\n x: rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width),\n y: rect.height === 0 ? 0 : clamp01((clientY - rect.top) / rect.height),\n };\n}\n\n/**\n * The saturation and value a point on the square stands for.\n *\n * Value runs UPWARD while a surface's y runs downward, so the vertical axis is\n * inverted here. Getting that wrong produces a picker that looks correct and\n * selects the colour vertically mirrored from the one under the cursor.\n *\n * @experimental\n */\nexport function saturationValueAt(point: SurfacePoint): {\n s: number;\n v: number;\n} {\n return { s: clamp01(point.x), v: 1 - clamp01(point.y) };\n}\n\n/**\n * Where the handle sits for a given saturation and value — the inverse of\n * {@link saturationValueAt}.\n *\n * Derived from that function's own inversion rather than written independently,\n * so the two cannot disagree about which way the axis runs.\n *\n * @experimental\n */\nexport function surfacePointFor(s: number, v: number): SurfacePoint {\n return { x: clamp01(s), y: 1 - clamp01(v) };\n}\n\n/**\n * The hue a horizontal position stands for, in [0, 360).\n *\n * The upper bound is exclusive: 360 and 0 are the same hue, and returning 360\n * lets a handle at the far end read back as a position outside the strip.\n *\n * @experimental\n */\nexport function hueAt(fraction: number): number {\n const hue = clamp01(fraction) * 360;\n return hue >= 360 ? 0 : hue;\n}\n\n/** Where the hue handle sits, as a fraction of the strip. @experimental */\nexport function huePosition(hue: number): number {\n const wrapped = ((hue % 360) + 360) % 360;\n return wrapped / 360;\n}\n\n/**\n * The integer step a hue occupies on a strip whose last step is `max`.\n *\n * Rounding is what makes this more than a multiplication. A hue just below 360\n * — `#ff0001` is about 359.76 — rounds UP to a step past the end of the strip,\n * and the obvious repair of wrapping it with a modulo sends it to 0: the far\n * LEFT, the opposite end from where that colour belongs. The handle then sits\n * on the wrong side and any keyboard adjustment starts from there. Held at the\n * last step instead, which is where it visually belongs.\n *\n * @experimental\n */\nexport function hueSliderValue(hue: number, max: number): number {\n const step = Math.round(huePosition(hue) * (max + 1));\n return step > max ? max : step;\n}\n"]}
@@ -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 };