color-bits 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/README.md +29 -13
  3. package/benchmarks/compare-oklab.ts +44 -0
  4. package/benchmarks/compare-srgb.ts +59 -0
  5. package/benchmarks/compare.ts +46 -0
  6. package/benchmarks/parse.ts +41 -0
  7. package/build/conversion/channels.d.ts +61 -0
  8. package/build/conversion/channels.js +353 -0
  9. package/build/conversion/channels.js.map +1 -0
  10. package/build/{convert.d.ts → conversion/color-spaces.d.ts} +25 -23
  11. package/build/conversion/color-spaces.js +263 -0
  12. package/build/conversion/color-spaces.js.map +1 -0
  13. package/build/core/bits.d.ts +28 -0
  14. package/build/{core.js → core/bits.js} +25 -26
  15. package/build/core/bits.js.map +1 -0
  16. package/build/core/bytes.d.ts +5 -0
  17. package/build/core/bytes.js +12 -0
  18. package/build/core/bytes.js.map +1 -0
  19. package/build/css.d.ts +1 -0
  20. package/build/css.js +18 -0
  21. package/build/css.js.map +1 -0
  22. package/build/formatting/index.d.ts +27 -0
  23. package/build/{format.js → formatting/index.js} +13 -67
  24. package/build/formatting/index.js.map +1 -0
  25. package/build/index.d.ts +6 -4
  26. package/build/index.js +6 -4
  27. package/build/index.js.map +1 -1
  28. package/build/namedColors.d.ts +1 -0
  29. package/build/namedColors.js +18 -0
  30. package/build/namedColors.js.map +1 -0
  31. package/build/operations/adjust.d.ts +19 -0
  32. package/build/operations/adjust.js +40 -0
  33. package/build/operations/adjust.js.map +1 -0
  34. package/build/operations/blend.d.ts +9 -0
  35. package/build/operations/blend.js +19 -0
  36. package/build/operations/blend.js.map +1 -0
  37. package/build/operations/color-mix.d.ts +20 -0
  38. package/build/operations/color-mix.js +144 -0
  39. package/build/operations/color-mix.js.map +1 -0
  40. package/build/operations/luminance.d.ts +7 -0
  41. package/build/operations/luminance.js +20 -0
  42. package/build/operations/luminance.js.map +1 -0
  43. package/build/parsing/calc.d.ts +8 -0
  44. package/build/parsing/calc.js +205 -0
  45. package/build/parsing/calc.js.map +1 -0
  46. package/build/parsing/color-mix.d.ts +8 -0
  47. package/build/parsing/color-mix.js +70 -0
  48. package/build/parsing/color-mix.js.map +1 -0
  49. package/build/parsing/css.d.ts +23 -0
  50. package/build/parsing/css.js +115 -0
  51. package/build/parsing/css.js.map +1 -0
  52. package/build/parsing/fast.d.ts +19 -0
  53. package/build/parsing/fast.js +184 -0
  54. package/build/parsing/fast.js.map +1 -0
  55. package/build/parsing/named-colors.d.ts +12 -0
  56. package/build/parsing/named-colors.js +68 -0
  57. package/build/parsing/named-colors.js.map +1 -0
  58. package/build/parsing/relative.d.ts +9 -0
  59. package/build/parsing/relative.js +158 -0
  60. package/build/parsing/relative.js.map +1 -0
  61. package/build/parsing/tokenizer.d.ts +16 -0
  62. package/build/parsing/tokenizer.js +78 -0
  63. package/build/parsing/tokenizer.js.map +1 -0
  64. package/build/parsing/values.d.ts +41 -0
  65. package/build/parsing/values.js +139 -0
  66. package/build/parsing/values.js.map +1 -0
  67. package/package.json +15 -3
  68. package/benchmarks/index.ts +0 -35
  69. package/build/bit.d.ts +0 -3
  70. package/build/bit.js +0 -29
  71. package/build/bit.js.map +0 -1
  72. package/build/convert.js +0 -347
  73. package/build/convert.js.map +0 -1
  74. package/build/core.d.ts +0 -27
  75. package/build/core.js.map +0 -1
  76. package/build/format.d.ts +0 -27
  77. package/build/format.js.map +0 -1
  78. package/build/functions.d.ts +0 -33
  79. package/build/functions.js +0 -95
  80. package/build/functions.js.map +0 -1
  81. package/build/parse.d.ts +0 -17
  82. package/build/parse.js +0 -392
  83. package/build/parse.js.map +0 -1
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git worktree *)",
5
+ "Bash(npx tsx *)",
6
+ "mcp__zym__set_worktree"
7
+ ]
8
+ }
9
+ }
package/README.md CHANGED
@@ -24,11 +24,13 @@ This library represents RGBA colors as a single `int32` number and avoids alloca
24
24
 
25
25
  | Library | Operations/sec | Relative speed |
26
26
  | --- | --: | --- |
27
- | **color-bits** | **22 966 299** | fastest |
28
- | colord | 4 308 547 | 81.24% slower |
29
- | tinycolor2 | 1 475 762 | 93.57% slower |
30
- | chroma-js | 846 924 | 96.31% slower |
31
- | color | 799 262 | 96.52% slower |
27
+ | **color-bits** | **84 428 994** | fastest |
28
+ | @texel/color | 7 160 587 | 91.52% slower |
29
+ | colord | 6 604 745 | 92.18% slower |
30
+ | culori | 5 223 367 | 93.81% slower |
31
+ | tinycolor2 | 2 479 660 | 97.06% slower |
32
+ | color | 1 440 968 | 98.29% slower |
33
+ | chroma-js | 1 348 236 | 98.40% slower |
32
34
 
33
35
  ### 🛠️ Install
34
36
 
@@ -36,19 +38,29 @@ This library represents RGBA colors as a single `int32` number and avoids alloca
36
38
  pnpm install color-bits
37
39
  ```
38
40
 
39
- ### 📑 Technical details
41
+ ### 📑 Usage
40
42
 
41
- Due to the compact representation, `color-bits` preserves **at most 8 bits of precision for each channel**, so an operation like `lighten(color, 0.000001)` would simply return the same color with no modification.
43
+ The fast `Color.parse` supports the full **CSS Color Module Level 4** color spaces in **absolute** representations (`#hex`, `rgb()`, `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()`, `color()`).
42
44
 
43
- For performance reasons, the color representation is `int32`, not `uint32`. It is expected if you see negative numbers when you print the raw color value. Use the formatting functions to transform the color representation back into a usable format.
45
+ Named colors, **relative** colors and `color-mix()` are supported through `parseCSS()` from the separate `color-bits/css` entry, which is slower and bigger. If you don't need those features, go for `parse()`.
44
46
 
45
- `color-bits` supports the full **CSS Color Module Level 4** color spaces **in absolute representations only**, so:
46
- - Yes: `oklab(59.69% 0.1007 0.1191)`
47
- - No: `oklab(from green l a b / 0.5)`
47
+ ```tsx
48
+ import { parseCSS } from 'color-bits/css'
49
+
50
+ parseCSS('rebeccapurple') // named colors
51
+ parseCSS('oklab(from green l a b / 0.5)') // relative colors
52
+ parseCSS('hsl(from red calc(h + 120) s l)') // relative colors with calc()
53
+ parseCSS('color-mix(in oklch, red 40%, blue)') // color-mix()
54
+
55
+ // currentColor / system colors need context, passed via `resolve`:
56
+ parseCSS('currentColor', { resolve: () => getComputedStyle(el).color })
57
+ ```
48
58
 
49
- When parsing and converting non-sRGB color spaces, `color-bits` behaves the same as browsers do, which differs from the formal CSS spec! In technical terms: non-sRGB color spaces with a wider gamut are converted using clipping rather than gamut-mapping.
59
+ **WARNING**: Due to the compact representation, `color-bits` preserves **at most 8 bits of precision for each channel**, so an operation like `lighten(color, 0.000001)` would simply return the same color with no modification. For performance reasons, the color representation is `int32`, not `uint32`. **It is expected if you see negative numbers when you print the raw color value**. Use the formatting functions to transform the color representation back into a usable format.
50
60
 
51
- Every function is tree-shakeable, so the bundle size cost should be from 1.5kb to 3kb, depending on which functions you use.
61
+ The named-color table lives in its own `color-bits/named` entry (`resolveNamed`, `namedColors`), and `color-mix` is also exposed programmatically over parsed colors from `color-bits/css`.
62
+
63
+ Every function is tree-shakeable, so the bundle size cost should be from 1.5kb to 3kb, depending on which functions you use. `parseCSS` and the named-color table are only pulled into your bundle if you import them.
52
64
 
53
65
  ### 📚 Documentation
54
66
 
@@ -73,6 +85,10 @@ import * as Color from 'color-bits/string'
73
85
  const output = Color.alpha('#232323', 0.5) // #RRGGBBAA string
74
86
  ```
75
87
 
88
+ ### Color conversion & gamut-mapping
89
+
90
+ When parsing and converting non-sRGB color spaces, `color-bits` behaves the same as browsers (Chrome in particular) do, which differs from the formal CSS spec! In technical terms: non-sRGB color spaces with a wider gamut are converted using clipping rather than gamut-mapping.
91
+
76
92
  ### 📜 License
77
93
 
78
94
  I release any of the code I wrote here to the public domain. Feel free to copy/paste in part or in full without attribution.
@@ -0,0 +1,44 @@
1
+ import b from 'benny'
2
+ import { alpha as colorBitsAlpha } from '../src/string'
3
+ import chroma from 'chroma-js'
4
+ import { parse as culoriParse, formatHex8 as culoriFormatHex8 } from 'culori'
5
+
6
+ const input = 'oklab(40.1% 0.1143 0.045)'
7
+
8
+ async function main() {
9
+ // @texel/color is ESM-only; load it dynamically so this file still runs under tsx's CJS.
10
+ const { parse: texelParse, sRGB, RGBToHex } = (await import('@texel/color')) as any
11
+
12
+ b.suite(
13
+ 'Parse an OKLab color, set to 0.5 opacity, and convert back to hexadecimal',
14
+
15
+ b.add('color-bits', () => {
16
+ colorBitsAlpha(input, 0.5)
17
+ }),
18
+
19
+ b.add('chroma-js', () => {
20
+ chroma(input).alpha(0.5).hex()
21
+ }),
22
+
23
+ b.add('culori', () => {
24
+ const parsed = culoriParse(input)!
25
+ parsed.alpha = 0.5
26
+ culoriFormatHex8(parsed)
27
+ }),
28
+
29
+ b.add('@texel/color', () => {
30
+ const rgb = texelParse(input, sRGB)
31
+ rgb[3] = 0.5
32
+ RGBToHex(rgb)
33
+ }),
34
+
35
+ b.cycle(),
36
+ b.complete((summary: any) => {
37
+ for (const r of summary.results) {
38
+ console.log(`RESULT ${r.name.padEnd(14)} ${Math.round(r.ops).toString().padStart(12)} ops/s`)
39
+ }
40
+ }),
41
+ )
42
+ }
43
+
44
+ main()
@@ -0,0 +1,59 @@
1
+ import b from 'benny'
2
+ import { alpha as colorBitsAlpha } from '../src/string'
3
+ import { colord } from 'colord'
4
+ import tinycolor2 from 'tinycolor2'
5
+ import color from 'color'
6
+ import chroma from 'chroma-js'
7
+ import { parse as culoriParse, formatHex8 as culoriFormatHex8 } from 'culori'
8
+
9
+ const input = '#808080'
10
+
11
+ async function main() {
12
+ // @texel/color is ESM-only; load it dynamically so this file still runs under tsx's CJS.
13
+ const { hexToRGB, RGBToHex } = (await import('@texel/color')) as any
14
+
15
+ b.suite(
16
+ `Parse color, set to 0.5 opacity, and convert back to hexadecimal`,
17
+
18
+ b.add('color-bits', () => {
19
+ colorBitsAlpha(input, 0.5)
20
+ }),
21
+
22
+ b.add('colord', () => {
23
+ colord(input).alpha(0.5).toHex()
24
+ }),
25
+
26
+ b.add('color', () => {
27
+ color(input).alpha(0.5).hex()
28
+ }),
29
+
30
+ b.add('tinycolor2', () => {
31
+ tinycolor2(input).setAlpha(0.5).toHexString()
32
+ }),
33
+
34
+ b.add('chroma-js', () => {
35
+ chroma(input).alpha(0.5).hex()
36
+ }),
37
+
38
+ b.add('culori', () => {
39
+ const parsed = culoriParse(input)!
40
+ parsed.alpha = 0.5
41
+ culoriFormatHex8(parsed)
42
+ }),
43
+
44
+ b.add('@texel/color', () => {
45
+ const rgb = hexToRGB(input)
46
+ rgb[3] = 0.5
47
+ RGBToHex(rgb)
48
+ }),
49
+
50
+ b.cycle(),
51
+ b.complete((summary: any) => {
52
+ for (const r of summary.results) {
53
+ console.log(`RESULT ${r.name.padEnd(14)} ${Math.round(r.ops).toString().padStart(12)} ops/s`)
54
+ }
55
+ }),
56
+ )
57
+ }
58
+
59
+ main()
@@ -0,0 +1,46 @@
1
+ import { parse } from '../src/index'
2
+
3
+ // Deterministic best-of-N micro-benchmark for one case, run in an isolated
4
+ // process to avoid cross-case JIT pollution. Usage:
5
+ // tsx benchmarks/compare.ts <case-name>
6
+ // Compare before/after a refactor by running it, then `git stash` + run again.
7
+
8
+ const cases: Record<string, string> = {
9
+ 'hex-short': '#59f',
10
+ 'hex': '#5599ff',
11
+ 'hex-alpha': '#5599ff88',
12
+ 'rgb': 'rgb(255 153 85)',
13
+ 'rgb-legacy': 'rgb(255, 153, 85)',
14
+ 'rgb-alpha': 'rgb(255 153 85 / 50%)',
15
+ 'hsl': 'hsl(50deg 80% 40%)',
16
+ 'hwb': 'hwb(50deg 30% 40%)',
17
+ 'lab': 'lab(50% 40 59.5)',
18
+ 'lch': 'lch(52.2% 72.2 50)',
19
+ 'oklab': 'oklab(40.1% 0.1143 0.045)',
20
+ 'oklch': 'oklch(40.1% 0.123 21.57)',
21
+ 'color-srgb': 'color(srgb 1 0.5 0)',
22
+ 'color-p3': 'color(display-p3 1 0.5 0)',
23
+ }
24
+
25
+ const name = process.argv[2]
26
+ const input = cases[name]
27
+ if (!input) { console.error(`unknown case: ${name}`); process.exit(1) }
28
+
29
+ const ITERATIONS = 1_000_000
30
+ const TRIALS = 15
31
+ let sink = 0
32
+
33
+ // warmup
34
+ for (let i = 0; i < 200_000; i++) sink ^= parse(input)
35
+
36
+ let best = Infinity
37
+ for (let t = 0; t < TRIALS; t++) {
38
+ const start = process.hrtime.bigint()
39
+ for (let i = 0; i < ITERATIONS; i++) sink ^= parse(input)
40
+ const ns = Number(process.hrtime.bigint() - start)
41
+ if (ns < best) best = ns
42
+ }
43
+ const opsPerSec = Math.round((ITERATIONS / best) * 1e9)
44
+ console.log(`RESULT ${name.padEnd(12)} ${opsPerSec.toString().padStart(12)} ops/s`)
45
+
46
+ if (sink === 42) console.log('') // prevent dead-code elimination
@@ -0,0 +1,41 @@
1
+ import b from 'benny'
2
+ import { parse } from '../src/index'
3
+
4
+ // Benchmarks the `parse` fast path across every absolute representation.
5
+ // Used as the regression gate for the channels.ts refactor: the hex hot path
6
+ // must stay flat, and the function parsers must not regress.
7
+
8
+ const cases: Record<string, string> = {
9
+ 'hex-short': '#59f',
10
+ 'hex': '#5599ff',
11
+ 'hex-alpha': '#5599ff88',
12
+ 'rgb': 'rgb(255 153 85)',
13
+ 'rgb-legacy': 'rgb(255, 153, 85)',
14
+ 'rgb-alpha': 'rgb(255 153 85 / 50%)',
15
+ 'hsl': 'hsl(50deg 80% 40%)',
16
+ 'hwb': 'hwb(50deg 30% 40%)',
17
+ 'lab': 'lab(50% 40 59.5)',
18
+ 'lch': 'lch(52.2% 72.2 50)',
19
+ 'oklab': 'oklab(40.1% 0.1143 0.045)',
20
+ 'oklch': 'oklch(40.1% 0.123 21.57)',
21
+ 'color-srgb': 'color(srgb 1 0.5 0)',
22
+ 'color-p3': 'color(display-p3 1 0.5 0)',
23
+ }
24
+
25
+ // XOR results into a sink so the (pure, number-returning) parse call cannot be
26
+ // dead-code-eliminated, which would inflate ops/s and hide regressions.
27
+ let sink = 0
28
+
29
+ b.suite(
30
+ 'Color.parse() — absolute representations',
31
+ ...Object.entries(cases).map(([name, input]) =>
32
+ b.add(name, () => { sink ^= parse(input) })
33
+ ),
34
+ b.cycle(),
35
+ b.complete((summary) => {
36
+ for (const r of summary.results) {
37
+ console.log(`RESULT ${r.name.padEnd(12)} ${Math.round(r.ops).toString().padStart(12)} ops/s`)
38
+ }
39
+ if (sink === 42) console.log('')
40
+ }),
41
+ )
@@ -0,0 +1,61 @@
1
+ import { ColorBits } from '../core/bits';
2
+ /** Build ColorBits from sRGB components in [0, 1] and an alpha byte (0..255). */
3
+ export declare function srgbToColor(r: number, g: number, b: number, a: number): ColorBits;
4
+ /**
5
+ * @param h degrees, any value (wrapped by 360°)
6
+ * @param s nominally 0..100 (clamped)
7
+ * @param l nominally 0..100 (clamped)
8
+ * @param a alpha byte
9
+ */
10
+ export declare function hslToColor(h: number, s: number, l: number, a: number): ColorBits;
11
+ /** @param sRGB in [0, 1] @returns out as [h degrees, s 0..100, l 0..100] */
12
+ export declare function srgbToHsl(r: number, g: number, b: number, out: Float64Array): Float64Array;
13
+ /**
14
+ * @param h degrees, any value (wrapped by 360°)
15
+ * @param w nominally 0..100 (clamped)
16
+ * @param b nominally 0..100 (clamped)
17
+ * @param a alpha byte
18
+ */
19
+ export declare function hwbToColor(h: number, w: number, b: number, a: number): ColorBits;
20
+ /** @param sRGB in [0, 1] @returns out as [h degrees, w 0..100, b 0..100] */
21
+ export declare function srgbToHwb(r: number, g: number, b: number, out: Float64Array): Float64Array;
22
+ /** @param l 0..100 @param a raw @param b raw @param alpha alpha byte */
23
+ export declare function labToColor(l: number, a: number, b: number, alpha: number): ColorBits;
24
+ /** @param sRGB in [0, 1] @returns out as [l 0..100, a raw, b raw] */
25
+ export declare function srgbToLab(r: number, g: number, b: number, out: Float64Array): Float64Array;
26
+ /** @param l 0..100 @param c raw @param h degrees @param alpha alpha byte */
27
+ export declare function lchToColor(l: number, c: number, h: number, alpha: number): ColorBits;
28
+ /** @param sRGB in [0, 1] @returns out as [l 0..100, c raw, h degrees] */
29
+ export declare function srgbToLch(r: number, g: number, b: number, out: Float64Array): Float64Array;
30
+ /** @param l 0..1 @param a raw @param b raw @param alpha alpha byte */
31
+ export declare function oklabToColor(l: number, a: number, b: number, alpha: number): ColorBits;
32
+ /** @param sRGB in [0, 1] @returns out as [l 0..1, a raw, b raw] */
33
+ export declare function srgbToOklab(r: number, g: number, b: number, out: Float64Array): Float64Array;
34
+ /** @param l 0..1 @param c raw @param h degrees @param alpha alpha byte */
35
+ export declare function oklchToColor(l: number, c: number, h: number, alpha: number): ColorBits;
36
+ /** @param sRGB in [0, 1] @returns out as [l 0..1, c raw, h degrees] */
37
+ export declare function srgbToOklch(r: number, g: number, b: number, out: Float64Array): Float64Array;
38
+ /** Channel keywords exposed by color(from <origin> <space> …) per color space. */
39
+ export declare function colorSpaceChannels(space: string): string[] | null;
40
+ /** @param channels in 0..1 @returns out as sRGB in [0, 1], or null for an unknown space */
41
+ export declare function colorSpaceToSrgb(space: string, c1: number, c2: number, c3: number, out: Float64Array): Float64Array | null;
42
+ /** @param channels in 0..1 @returns ColorBits, or null for an unknown space */
43
+ export declare function colorSpaceToColor(space: string, c1: number, c2: number, c3: number, alpha: number): ColorBits | null;
44
+ /** @param sRGB in [0, 1] @returns out as the space's channels in 0..1, or null for an unknown space */
45
+ export declare function srgbToColorSpace(space: string, r: number, g: number, b: number, out: Float64Array): Float64Array | null;
46
+ export interface ColorModel {
47
+ keys: [string, string, string];
48
+ /** percentage reference per channel (e.g. 100 for lab L, 360 for hues) */
49
+ ranges: [number, number, number];
50
+ /** whether each channel is a hue angle */
51
+ hues: [boolean, boolean, boolean];
52
+ /** sRGB in [0, 1] -> the model's keyword-unit channels, written into `out` and returned */
53
+ fromSrgb: (r: number, g: number, b: number, out: Float64Array) => Float64Array;
54
+ /** keyword-unit channels + alpha byte -> ColorBits */
55
+ toColor: (c1: number, c2: number, c3: number, alpha: number) => ColorBits;
56
+ }
57
+ /** Model for a color-model name (hsl, oklch, …), or null. Own-key lookup so
58
+ * that Object.prototype names ('constructor', '__proto__') are not models. */
59
+ export declare function colorModel(name: string): ColorModel | null;
60
+ /** Model for a color() predefined space (channels in 0..1), or null. */
61
+ export declare function colorSpaceModel(space: string): ColorModel | null;