@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 +102 -24
- 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 +3164 -1102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1220 -315
- package/dist/index.d.ts +1220 -315
- package/dist/index.mjs +2887 -839
- 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 +1117 -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 +77 -17
package/README.md
CHANGED
|
@@ -31,56 +31,134 @@ pnpm add react react-dom lucide-react
|
|
|
31
31
|
|
|
32
32
|
## Setup
|
|
33
33
|
|
|
34
|
-
The
|
|
34
|
+
The package ships three CSS entry points. Pick one:
|
|
35
35
|
|
|
36
|
-
**
|
|
36
|
+
**Zero config — pre-compiled bundle**
|
|
37
37
|
|
|
38
|
-
|
|
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
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
```tsx
|
|
43
|
+
import "@nextlyhq/ui/styles.css";
|
|
44
|
+
import { Button } from "@nextlyhq/ui";
|
|
43
45
|
|
|
44
|
-
|
|
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
|
-
|
|
49
|
+
**Bring your own Tailwind v4 build**
|
|
54
50
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+ (
|
|
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"]}
|
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 };
|