tweakit 0.6.0 → 0.7.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 (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
package/src/wide-gamut.ts CHANGED
@@ -6,11 +6,31 @@ import type { ColorMode, GradientInterpolation } from "./tweaks/types.js";
6
6
  export type Vec3 = [number, number, number];
7
7
  type Mat3 = [Vec3, Vec3, Vec3];
8
8
  /** The spaces convert() hubs between, through CIE XYZ. */
9
- export type Space = "srgb" | "p3" | "rec2020" | "prophoto-rgb" | "a98-rgb" | "srgb-linear" | "xyz-d65" | "xyz-d50" | "oklab" | "oklch" | "lab" | "lch" | "hsl" | "hwb";
9
+ export type Space =
10
+ | "srgb"
11
+ | "p3"
12
+ | "rec2020"
13
+ | "prophoto-rgb"
14
+ | "a98-rgb"
15
+ | "srgb-linear"
16
+ | "xyz-d65"
17
+ | "xyz-d50"
18
+ | "oklab"
19
+ | "oklch"
20
+ | "lab"
21
+ | "lch"
22
+ | "hsl"
23
+ | "hwb";
10
24
  /** The RGB gamuts a color can be mapped into. */
11
25
  export type Gamut = "srgb" | "p3" | "rec2020";
12
26
  /** One editable channel of a mode: label, display range and step, engine-to-display scale. */
13
- export interface Channel { k: string; min: number; max: number; step: number; scale: number }
27
+ export interface Channel {
28
+ k: string;
29
+ min: number;
30
+ max: number;
31
+ step: number;
32
+ scale: number;
33
+ }
14
34
 
15
35
  const DEG = 180 / Math.PI;
16
36
  const RAD = Math.PI / 180;
@@ -20,8 +40,13 @@ const mul = (m: Mat3, v: Vec3): Vec3 => [
20
40
  m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
21
41
  ];
22
42
  const mulMat = (a: Mat3, b: Mat3): Mat3 => {
23
- const o: Mat3 = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
24
- for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) o[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
43
+ const o: Mat3 = [
44
+ [0, 0, 0],
45
+ [0, 0, 0],
46
+ [0, 0, 0],
47
+ ];
48
+ for (let i = 0; i < 3; i++)
49
+ for (let j = 0; j < 3; j++) o[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
25
50
  return o;
26
51
  };
27
52
  // Non-finite channels (null, NaN, ±Infinity) fall to 0; an infinite chroma would hang toGamut's bisection.
@@ -29,113 +54,272 @@ export const num = (x: number) => (Number.isFinite(x) ? x : 0);
29
54
  const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x));
30
55
 
31
56
  // ── transfer functions ──
32
- const srgbLin = (c: number) => { const a = Math.abs(c); return a <= 0.04045 ? c / 12.92 : Math.sign(c) * ((a + 0.055) / 1.055) ** 2.4; };
33
- const srgbGam = (c: number) => { const a = Math.abs(c); return a <= 0.0031308 ? c * 12.92 : Math.sign(c) * (1.055 * a ** (1 / 2.4) - 0.055); };
34
- const REC_A = 1.09929682680944, REC_B = 0.018053968510807; // BT.2020 piecewise OETF constants, not a pure gamma
35
- const rec2020Lin = (c: number) => { const a = Math.abs(c); return a < REC_B * 4.5 ? c / 4.5 : Math.sign(c) * ((a + REC_A - 1) / REC_A) ** (1 / 0.45); };
36
- const rec2020Gam = (c: number) => { const a = Math.abs(c); return a < REC_B ? c * 4.5 : Math.sign(c) * (REC_A * a ** 0.45 - (REC_A - 1)); };
57
+ const srgbLin = (c: number) => {
58
+ const a = Math.abs(c);
59
+ return a <= 0.04045 ? c / 12.92 : Math.sign(c) * ((a + 0.055) / 1.055) ** 2.4;
60
+ };
61
+ const srgbGam = (c: number) => {
62
+ const a = Math.abs(c);
63
+ return a <= 0.0031308 ? c * 12.92 : Math.sign(c) * (1.055 * a ** (1 / 2.4) - 0.055);
64
+ };
65
+ const REC_A = 1.09929682680944,
66
+ REC_B = 0.018053968510807; // BT.2020 piecewise OETF constants, not a pure gamma
67
+ const rec2020Lin = (c: number) => {
68
+ const a = Math.abs(c);
69
+ return a < REC_B * 4.5 ? c / 4.5 : Math.sign(c) * ((a + REC_A - 1) / REC_A) ** (1 / 0.45);
70
+ };
71
+ const rec2020Gam = (c: number) => {
72
+ const a = Math.abs(c);
73
+ return a < REC_B ? c * 4.5 : Math.sign(c) * (REC_A * a ** 0.45 - (REC_A - 1));
74
+ };
37
75
  const PRO_ET = 1 / 512;
38
- const prophotoLin = (c: number) => { const a = Math.abs(c); return a <= PRO_ET * 16 ? c / 16 : Math.sign(c) * a ** 1.8; };
39
- const prophotoGam = (c: number) => { const a = Math.abs(c); return a >= PRO_ET ? Math.sign(c) * a ** (1 / 1.8) : 16 * c; };
76
+ const prophotoLin = (c: number) => {
77
+ const a = Math.abs(c);
78
+ return a <= PRO_ET * 16 ? c / 16 : Math.sign(c) * a ** 1.8;
79
+ };
80
+ const prophotoGam = (c: number) => {
81
+ const a = Math.abs(c);
82
+ return a >= PRO_ET ? Math.sign(c) * a ** (1 / 1.8) : 16 * c;
83
+ };
40
84
 
41
85
  // ── matrices (D65 unless noted) ──
42
- const LIN_SRGB_TO_XYZ: Mat3 = [[0.41239079926595934, 0.357584339383878, 0.1804807884018343], [0.21263900587151027, 0.715168678767756, 0.07219231536073371], [0.01933081871559182, 0.11919477979462598, 0.9505321522496607]];
43
- const XYZ_TO_LIN_SRGB: Mat3 = [[3.2409699419045226, -1.537383177570094, -0.4986107602930034], [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559], [0.05563007969699366, -0.20397695888897652, 1.0569715142428786]];
44
- const LIN_P3_TO_XYZ: Mat3 = [[0.4865709486482162, 0.26566769316909306, 0.19821728523436247], [0.2289745640697488, 0.6917385218365064, 0.079286914093745], [0, 0.04511338185890264, 1.043944368900976]];
45
- const XYZ_TO_LIN_P3: Mat3 = [[2.493496911941425, -0.9313836179191239, -0.40271078445071684], [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577], [0.03584583024378447, -0.07617238926804182, 0.9568845240076872]];
46
- const LIN_REC2020_TO_XYZ: Mat3 = [[0.6369580483012914, 0.14461690358620832, 0.16888097516417205], [0.2627002120112671, 0.6779980715188708, 0.05930171646986196], [0, 0.028072693049087428, 1.060985057710791]];
47
- const XYZ_TO_LIN_REC2020: Mat3 = [[1.7166511879712674, -0.35567078377639233, -0.25336628137365974], [-0.6666843518324892, 1.6164812366349395, 0.01576854581391113], [0.017639857445310783, -0.042770613257808524, 0.9421031212354738]];
48
- const LIN_PRO_TO_XYZ_D50: Mat3 = [[0.7977604896723027, 0.13518583717574031, 0.0313493495815248], [0.2880711282292934, 0.7118432178101014, 0.00008565396060525902], [0, 0, 0.8251046025104601]];
49
- const XYZ_D50_TO_LIN_PRO: Mat3 = [[1.3457989731028281, -0.25558010007997534, -0.05110628506753401], [-0.5446224939028347, 1.5082327413132781, 0.02053603239147973], [0, 0, 1.2119675456389454]];
86
+ const LIN_SRGB_TO_XYZ: Mat3 = [
87
+ [0.41239079926595934, 0.357584339383878, 0.1804807884018343],
88
+ [0.21263900587151027, 0.715168678767756, 0.07219231536073371],
89
+ [0.01933081871559182, 0.11919477979462598, 0.9505321522496607],
90
+ ];
91
+ const XYZ_TO_LIN_SRGB: Mat3 = [
92
+ [3.2409699419045226, -1.537383177570094, -0.4986107602930034],
93
+ [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559],
94
+ [0.05563007969699366, -0.20397695888897652, 1.0569715142428786],
95
+ ];
96
+ const LIN_P3_TO_XYZ: Mat3 = [
97
+ [0.4865709486482162, 0.26566769316909306, 0.19821728523436247],
98
+ [0.2289745640697488, 0.6917385218365064, 0.079286914093745],
99
+ [0, 0.04511338185890264, 1.043944368900976],
100
+ ];
101
+ const XYZ_TO_LIN_P3: Mat3 = [
102
+ [2.493496911941425, -0.9313836179191239, -0.40271078445071684],
103
+ [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577],
104
+ [0.03584583024378447, -0.07617238926804182, 0.9568845240076872],
105
+ ];
106
+ const LIN_REC2020_TO_XYZ: Mat3 = [
107
+ [0.6369580483012914, 0.14461690358620832, 0.16888097516417205],
108
+ [0.2627002120112671, 0.6779980715188708, 0.05930171646986196],
109
+ [0, 0.028072693049087428, 1.060985057710791],
110
+ ];
111
+ const XYZ_TO_LIN_REC2020: Mat3 = [
112
+ [1.7166511879712674, -0.35567078377639233, -0.25336628137365974],
113
+ [-0.6666843518324892, 1.6164812366349395, 0.01576854581391113],
114
+ [0.017639857445310783, -0.042770613257808524, 0.9421031212354738],
115
+ ];
116
+ const LIN_PRO_TO_XYZ_D50: Mat3 = [
117
+ [0.7977604896723027, 0.13518583717574031, 0.0313493495815248],
118
+ [0.2880711282292934, 0.7118432178101014, 0.00008565396060525902],
119
+ [0, 0, 0.8251046025104601],
120
+ ];
121
+ const XYZ_D50_TO_LIN_PRO: Mat3 = [
122
+ [1.3457989731028281, -0.25558010007997534, -0.05110628506753401],
123
+ [-0.5446224939028347, 1.5082327413132781, 0.02053603239147973],
124
+ [0, 0, 1.2119675456389454],
125
+ ];
50
126
  // CSS Color 4's a98-rgb (Adobe RGB 1998): a pure 563/256 gamma over these primaries, D65.
51
- const A98_GAMMA = 563 / 256, a98Lin = (c: number) => Math.sign(c) * Math.abs(c) ** A98_GAMMA, a98Gam = (c: number) => Math.sign(c) * Math.abs(c) ** (1 / A98_GAMMA);
52
- const LIN_A98_TO_XYZ: Mat3 = [[573536 / 994567, 263643 / 1420810, 187206 / 994567], [591459 / 1989134, 6239551 / 9945670, 374412 / 4972835], [53769 / 1989134, 351524 / 4972835, 4929758 / 4972835]];
53
- const XYZ_TO_LIN_A98: Mat3 = [[1829569 / 896150, -506331 / 896150, -308931 / 896150], [-851781 / 878810, 1648619 / 878810, 36519 / 878810], [16779 / 1248040, -147721 / 1248040, 1266979 / 1248040]];
127
+ const A98_GAMMA = 563 / 256,
128
+ a98Lin = (c: number) => Math.sign(c) * Math.abs(c) ** A98_GAMMA,
129
+ a98Gam = (c: number) => Math.sign(c) * Math.abs(c) ** (1 / A98_GAMMA);
130
+ const LIN_A98_TO_XYZ: Mat3 = [
131
+ [573536 / 994567, 263643 / 1420810, 187206 / 994567],
132
+ [591459 / 1989134, 6239551 / 9945670, 374412 / 4972835],
133
+ [53769 / 1989134, 351524 / 4972835, 4929758 / 4972835],
134
+ ];
135
+ const XYZ_TO_LIN_A98: Mat3 = [
136
+ [1829569 / 896150, -506331 / 896150, -308931 / 896150],
137
+ [-851781 / 878810, 1648619 / 878810, 36519 / 878810],
138
+ [16779 / 1248040, -147721 / 1248040, 1266979 / 1248040],
139
+ ];
54
140
  const same = (c: number) => c;
55
- const XYZ_D65_TO_D50: Mat3 = [[1.0479298208405488, 0.022946793341019088, -0.05019222954313557], [0.029627815688159344, 0.990434484573249, -0.01707382502938514], [-0.009243058152591178, 0.015055144896577895, 0.7518742899580008]];
56
- const XYZ_D50_TO_D65: Mat3 = [[0.9554734527042182, -0.023098536874261423, 0.0632593086610217], [-0.028369706963208136, 1.0099954580058226, 0.021041398966943008], [0.012314001688319899, -0.020507696433477912, 1.3303659366080753]];
57
- const XYZ_TO_LMS: Mat3 = [[0.819022437996703, 0.3619062600528904, -0.1288737815209879], [0.0329836539323885, 0.9292868615863434, 0.0361446663506424], [0.0481771893596242, 0.2642395317527308, 0.6335478284694309]];
58
- const LMS_TO_XYZ: Mat3 = [[1.2268798758459243, -0.5578149944602171, 0.2813910456659647], [-0.0405757452148008, 1.112286803280317, -0.0717110580655164], [-0.0763729366746601, -0.4214933324022432, 1.5869240198367816]];
59
- const LMS_TO_OKLAB: Mat3 = [[0.210454268309314, 0.7936177747023054, -0.0040720430116193], [1.9779985324311684, -2.42859224204858, 0.450593709617411], [0.0259040424655478, 0.7827717124575296, -0.8086757549230774]];
60
- const OKLAB_TO_LMS: Mat3 = [[1.0, 0.3963377773761749, 0.2158037573099136], [1.0, -0.1055613458156586, -0.0638541728258133], [1.0, -0.0894841775298119, -1.2914855480194092]];
61
- const LAB_E = 216 / 24389, LAB_K = 24389 / 27;
141
+ const XYZ_D65_TO_D50: Mat3 = [
142
+ [1.0479298208405488, 0.022946793341019088, -0.05019222954313557],
143
+ [0.029627815688159344, 0.990434484573249, -0.01707382502938514],
144
+ [-0.009243058152591178, 0.015055144896577895, 0.7518742899580008],
145
+ ];
146
+ const XYZ_D50_TO_D65: Mat3 = [
147
+ [0.9554734527042182, -0.023098536874261423, 0.0632593086610217],
148
+ [-0.028369706963208136, 1.0099954580058226, 0.021041398966943008],
149
+ [0.012314001688319899, -0.020507696433477912, 1.3303659366080753],
150
+ ];
151
+ const XYZ_TO_LMS: Mat3 = [
152
+ [0.819022437996703, 0.3619062600528904, -0.1288737815209879],
153
+ [0.0329836539323885, 0.9292868615863434, 0.0361446663506424],
154
+ [0.0481771893596242, 0.2642395317527308, 0.6335478284694309],
155
+ ];
156
+ const LMS_TO_XYZ: Mat3 = [
157
+ [1.2268798758459243, -0.5578149944602171, 0.2813910456659647],
158
+ [-0.0405757452148008, 1.112286803280317, -0.0717110580655164],
159
+ [-0.0763729366746601, -0.4214933324022432, 1.5869240198367816],
160
+ ];
161
+ const LMS_TO_OKLAB: Mat3 = [
162
+ [0.210454268309314, 0.7936177747023054, -0.0040720430116193],
163
+ [1.9779985324311684, -2.42859224204858, 0.450593709617411],
164
+ [0.0259040424655478, 0.7827717124575296, -0.8086757549230774],
165
+ ];
166
+ const OKLAB_TO_LMS: Mat3 = [
167
+ [1.0, 0.3963377773761749, 0.2158037573099136],
168
+ [1.0, -0.1055613458156586, -0.0638541728258133],
169
+ [1.0, -0.0894841775298119, -1.2914855480194092],
170
+ ];
171
+ const LAB_E = 216 / 24389,
172
+ LAB_K = 24389 / 27;
62
173
  const WHITE_D50: Vec3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585];
63
174
 
64
175
  const rgbToXyz = (c: Vec3, lin: (c: number) => number, m: Mat3) => mul(m, [lin(c[0]), lin(c[1]), lin(c[2])]);
65
- const xyzToRgb = (xyz: Vec3, gam: (c: number) => number, m: Mat3): Vec3 => { const l = mul(m, xyz); return [gam(l[0]), gam(l[1]), gam(l[2])]; };
66
- const oklabToXyz = (lab: Vec3) => { const p = mul(OKLAB_TO_LMS, lab); return mul(LMS_TO_XYZ, [p[0] ** 3, p[1] ** 3, p[2] ** 3]); };
67
- const xyzToOklab = (xyz: Vec3) => { const lms = mul(XYZ_TO_LMS, xyz); return mul(LMS_TO_OKLAB, [Math.cbrt(lms[0]), Math.cbrt(lms[1]), Math.cbrt(lms[2])]); };
176
+ const xyzToRgb = (xyz: Vec3, gam: (c: number) => number, m: Mat3): Vec3 => {
177
+ const l = mul(m, xyz);
178
+ return [gam(l[0]), gam(l[1]), gam(l[2])];
179
+ };
180
+ const oklabToXyz = (lab: Vec3) => {
181
+ const p = mul(OKLAB_TO_LMS, lab);
182
+ return mul(LMS_TO_XYZ, [p[0] ** 3, p[1] ** 3, p[2] ** 3]);
183
+ };
184
+ const xyzToOklab = (xyz: Vec3) => {
185
+ const lms = mul(XYZ_TO_LMS, xyz);
186
+ return mul(LMS_TO_OKLAB, [Math.cbrt(lms[0]), Math.cbrt(lms[1]), Math.cbrt(lms[2])]);
187
+ };
68
188
  function labToXyz(lab: Vec3): Vec3 {
69
- const [L, a, b] = lab; const fy = (L + 16) / 116, fx = a / 500 + fy, fz = fy - b / 200;
189
+ const [L, a, b] = lab;
190
+ const fy = (L + 16) / 116,
191
+ fx = a / 500 + fy,
192
+ fz = fy - b / 200;
70
193
  const x = fx ** 3 > LAB_E ? fx ** 3 : (116 * fx - 16) / LAB_K;
71
194
  const y = L > LAB_K * LAB_E ? fy ** 3 : L / LAB_K;
72
195
  const z = fz ** 3 > LAB_E ? fz ** 3 : (116 * fz - 16) / LAB_K;
73
196
  return mul(XYZ_D50_TO_D65, [x * WHITE_D50[0], y * WHITE_D50[1], z * WHITE_D50[2]]);
74
197
  }
75
198
  function xyzToLab(xyz: Vec3): Vec3 {
76
- const d50 = mul(XYZ_D65_TO_D50, xyz); const f = (t: number) => (t > LAB_E ? Math.cbrt(t) : (LAB_K * t + 16) / 116);
77
- const fx = f(d50[0] / WHITE_D50[0]), fy = f(d50[1] / WHITE_D50[1]), fz = f(d50[2] / WHITE_D50[2]);
199
+ const d50 = mul(XYZ_D65_TO_D50, xyz);
200
+ const f = (t: number) => (t > LAB_E ? Math.cbrt(t) : (LAB_K * t + 16) / 116);
201
+ const fx = f(d50[0] / WHITE_D50[0]),
202
+ fy = f(d50[1] / WHITE_D50[1]),
203
+ fz = f(d50[2] / WHITE_D50[2]);
78
204
  return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
79
205
  }
80
- const toPolar = (r: Vec3): Vec3 => { const C = Math.hypot(r[1], r[2]); let h = Math.atan2(r[2], r[1]) * DEG; if (h < 0) h += 360; return [r[0], C, h]; };
206
+ const toPolar = (r: Vec3): Vec3 => {
207
+ const C = Math.hypot(r[1], r[2]);
208
+ let h = Math.atan2(r[2], r[1]) * DEG;
209
+ if (h < 0) h += 360;
210
+ return [r[0], C, h];
211
+ };
81
212
  const toRect = (p: Vec3): Vec3 => [p[0], p[1] * Math.cos(p[2] * RAD), p[1] * Math.sin(p[2] * RAD)];
82
213
  function hslToSrgb(hsl: Vec3): Vec3 {
83
- const h = (((Number.isNaN(hsl[0]) ? 0 : hsl[0]) % 360) + 360) % 360, s = hsl[1] / 100, l = hsl[2] / 100;
84
- const f = (n: number) => { const k = (n + h / 30) % 12, a = s * Math.min(l, 1 - l); return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
214
+ const h = (((Number.isNaN(hsl[0]) ? 0 : hsl[0]) % 360) + 360) % 360,
215
+ s = hsl[1] / 100,
216
+ l = hsl[2] / 100;
217
+ const f = (n: number) => {
218
+ const k = (n + h / 30) % 12,
219
+ a = s * Math.min(l, 1 - l);
220
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
221
+ };
85
222
  return [f(0), f(8), f(4)];
86
223
  }
87
224
  function srgbHue(rgb: Vec3) {
88
- const max = Math.max(...rgb), min = Math.min(...rgb), d = max - min;
225
+ const max = Math.max(...rgb),
226
+ min = Math.min(...rgb),
227
+ d = max - min;
89
228
  if (d < 1e-9) return NaN; // a gray; the hue would be float noise
90
- let h: number; if (max === rgb[0]) h = (rgb[1] - rgb[2]) / d + (rgb[1] < rgb[2] ? 6 : 0); else if (max === rgb[1]) h = (rgb[2] - rgb[0]) / d + 2; else h = (rgb[0] - rgb[1]) / d + 4;
229
+ let h: number;
230
+ if (max === rgb[0]) h = (rgb[1] - rgb[2]) / d + (rgb[1] < rgb[2] ? 6 : 0);
231
+ else if (max === rgb[1]) h = (rgb[2] - rgb[0]) / d + 2;
232
+ else h = (rgb[0] - rgb[1]) / d + 4;
91
233
  return h * 60;
92
234
  }
93
235
  function srgbToHsl(rgb: Vec3): Vec3 {
94
- const max = Math.max(...rgb), min = Math.min(...rgb), l = (min + max) / 2;
95
- let h = srgbHue(rgb), s = max === min || l === 0 || l === 1 ? 0 : (max - l) / Math.min(l, 1 - l);
96
- if (s < 0) { h += 180; s = -s; } if (h >= 360) h -= 360;
236
+ const max = Math.max(...rgb),
237
+ min = Math.min(...rgb),
238
+ l = (min + max) / 2;
239
+ let h = srgbHue(rgb),
240
+ s = max === min || l === 0 || l === 1 ? 0 : (max - l) / Math.min(l, 1 - l);
241
+ if (s < 0) {
242
+ h += 180;
243
+ s = -s;
244
+ }
245
+ if (h >= 360) h -= 360;
97
246
  return [h, s * 100, l * 100];
98
247
  }
99
248
  function hwbToSrgb(hwb: Vec3): Vec3 {
100
- const w = hwb[1] / 100, b = hwb[2] / 100; if (w + b >= 1) { const g = w / (w + b); return [g, g, g]; }
101
- const rgb = hslToSrgb([hwb[0], 100, 50]), scale = 1 - w - b; return [rgb[0] * scale + w, rgb[1] * scale + w, rgb[2] * scale + w];
249
+ const w = hwb[1] / 100,
250
+ b = hwb[2] / 100;
251
+ if (w + b >= 1) {
252
+ const g = w / (w + b);
253
+ return [g, g, g];
254
+ }
255
+ const rgb = hslToSrgb([hwb[0], 100, 50]),
256
+ scale = 1 - w - b;
257
+ return [rgb[0] * scale + w, rgb[1] * scale + w, rgb[2] * scale + w];
102
258
  }
103
259
  const srgbToHwb = (rgb: Vec3): Vec3 => [srgbHue(rgb), Math.min(...rgb) * 100, (1 - Math.max(...rgb)) * 100];
104
260
 
105
261
  function toXyz(c: Vec3, space: Space): Vec3 {
106
262
  switch (space) {
107
- case "srgb": return rgbToXyz(c, srgbLin, LIN_SRGB_TO_XYZ);
108
- case "p3": return rgbToXyz(c, srgbLin, LIN_P3_TO_XYZ);
109
- case "rec2020": return rgbToXyz(c, rec2020Lin, LIN_REC2020_TO_XYZ);
110
- case "prophoto-rgb": return mul(XYZ_D50_TO_D65, rgbToXyz(c, prophotoLin, LIN_PRO_TO_XYZ_D50));
111
- case "a98-rgb": return rgbToXyz(c, a98Lin, LIN_A98_TO_XYZ);
112
- case "srgb-linear": return mul(LIN_SRGB_TO_XYZ, c);
113
- case "xyz-d65": return [c[0], c[1], c[2]];
114
- case "xyz-d50": return mul(XYZ_D50_TO_D65, c);
115
- case "oklab": return oklabToXyz(c);
116
- case "oklch": return oklabToXyz(toRect(c));
117
- case "lab": return labToXyz(c);
118
- case "lch": return labToXyz(toRect(c));
119
- case "hsl": return rgbToXyz(hslToSrgb(c), srgbLin, LIN_SRGB_TO_XYZ);
120
- case "hwb": return rgbToXyz(hwbToSrgb(c), srgbLin, LIN_SRGB_TO_XYZ);
263
+ case "srgb":
264
+ return rgbToXyz(c, srgbLin, LIN_SRGB_TO_XYZ);
265
+ case "p3":
266
+ return rgbToXyz(c, srgbLin, LIN_P3_TO_XYZ);
267
+ case "rec2020":
268
+ return rgbToXyz(c, rec2020Lin, LIN_REC2020_TO_XYZ);
269
+ case "prophoto-rgb":
270
+ return mul(XYZ_D50_TO_D65, rgbToXyz(c, prophotoLin, LIN_PRO_TO_XYZ_D50));
271
+ case "a98-rgb":
272
+ return rgbToXyz(c, a98Lin, LIN_A98_TO_XYZ);
273
+ case "srgb-linear":
274
+ return mul(LIN_SRGB_TO_XYZ, c);
275
+ case "xyz-d65":
276
+ return [c[0], c[1], c[2]];
277
+ case "xyz-d50":
278
+ return mul(XYZ_D50_TO_D65, c);
279
+ case "oklab":
280
+ return oklabToXyz(c);
281
+ case "oklch":
282
+ return oklabToXyz(toRect(c));
283
+ case "lab":
284
+ return labToXyz(c);
285
+ case "lch":
286
+ return labToXyz(toRect(c));
287
+ case "hsl":
288
+ return rgbToXyz(hslToSrgb(c), srgbLin, LIN_SRGB_TO_XYZ);
289
+ case "hwb":
290
+ return rgbToXyz(hwbToSrgb(c), srgbLin, LIN_SRGB_TO_XYZ);
121
291
  }
122
292
  }
123
293
  function fromXyz(xyz: Vec3, space: Space): Vec3 {
124
294
  switch (space) {
125
- case "srgb": return xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB);
126
- case "p3": return xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_P3);
127
- case "rec2020": return xyzToRgb(xyz, rec2020Gam, XYZ_TO_LIN_REC2020);
128
- case "prophoto-rgb": return xyzToRgb(mul(XYZ_D65_TO_D50, xyz), prophotoGam, XYZ_D50_TO_LIN_PRO);
129
- case "a98-rgb": return xyzToRgb(xyz, a98Gam, XYZ_TO_LIN_A98);
130
- case "srgb-linear": return xyzToRgb(xyz, same, XYZ_TO_LIN_SRGB);
131
- case "xyz-d65": return [xyz[0], xyz[1], xyz[2]];
132
- case "xyz-d50": return mul(XYZ_D65_TO_D50, xyz);
133
- case "oklab": return xyzToOklab(xyz);
134
- case "oklch": return toPolar(xyzToOklab(xyz));
135
- case "lab": return xyzToLab(xyz);
136
- case "lch": return toPolar(xyzToLab(xyz));
137
- case "hsl": return srgbToHsl(xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB));
138
- case "hwb": return srgbToHwb(xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB));
295
+ case "srgb":
296
+ return xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB);
297
+ case "p3":
298
+ return xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_P3);
299
+ case "rec2020":
300
+ return xyzToRgb(xyz, rec2020Gam, XYZ_TO_LIN_REC2020);
301
+ case "prophoto-rgb":
302
+ return xyzToRgb(mul(XYZ_D65_TO_D50, xyz), prophotoGam, XYZ_D50_TO_LIN_PRO);
303
+ case "a98-rgb":
304
+ return xyzToRgb(xyz, a98Gam, XYZ_TO_LIN_A98);
305
+ case "srgb-linear":
306
+ return xyzToRgb(xyz, same, XYZ_TO_LIN_SRGB);
307
+ case "xyz-d65":
308
+ return [xyz[0], xyz[1], xyz[2]];
309
+ case "xyz-d50":
310
+ return mul(XYZ_D65_TO_D50, xyz);
311
+ case "oklab":
312
+ return xyzToOklab(xyz);
313
+ case "oklch":
314
+ return toPolar(xyzToOklab(xyz));
315
+ case "lab":
316
+ return xyzToLab(xyz);
317
+ case "lch":
318
+ return toPolar(xyzToLab(xyz));
319
+ case "hsl":
320
+ return srgbToHsl(xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB));
321
+ case "hwb":
322
+ return srgbToHwb(xyzToRgb(xyz, srgbGam, XYZ_TO_LIN_SRGB));
139
323
  }
140
324
  }
141
325
  export function convert(coords: Vec3, from: Space, to: Space): Vec3 {
@@ -151,38 +335,70 @@ export function convert(coords: Vec3, from: Space, to: Space): Vec3 {
151
335
  const SLACK = 0.000075;
152
336
  const inRange = (rgb: Vec3) => rgb.every((c) => c >= -SLACK && c <= 1 + SLACK);
153
337
  const inGamut = (oklch: Vec3, gamut: Gamut) => inRange(convert(oklch, "oklch", gamut));
154
- const clip = (rgb: Vec3) => rgb.map((c) => Math.min(1, Math.max(0, c))) as Vec3; // map() on a tuple reads back as an array
338
+ // map() on a tuple reads back as an array
339
+ const clip = (rgb: Vec3) => rgb.map((c) => Math.min(1, Math.max(0, c))) as Vec3;
155
340
  const deltaEOK = (a: Vec3, b: Vec3) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
156
341
  /** Map OKLCH into an RGB gamut (CSS Color 4 chroma reduction + local clip). */
157
342
  export function toGamut(oklch: Vec3, dest: Gamut): Vec3 {
158
343
  if (inGamut(oklch, dest)) return convert(oklch, "oklch", dest);
159
- const L = oklch[0]; if (L >= 1) return [1, 1, 1]; if (L <= 0) return [0, 0, 0];
344
+ const L = oklch[0];
345
+ if (L >= 1) return [1, 1, 1];
346
+ if (L <= 0) return [0, 0, 0];
160
347
  // An unbounded chroma can't be bisected; fall to the achromatic color at this lightness.
161
348
  if (!Number.isFinite(oklch[1])) return clip(convert([L, 0, num(oklch[2])], "oklch", dest));
162
- const JND = 0.02, EPS = 0.0001; const cur: Vec3 = [oklch[0], oklch[1], oklch[2]];
163
- let min = 0, max = oklch[1], minIn = true, clipped = clip(convert(cur, "oklch", dest));
349
+ const JND = 0.02,
350
+ EPS = 0.0001;
351
+ const cur: Vec3 = [oklch[0], oklch[1], oklch[2]];
352
+ let min = 0,
353
+ max = oklch[1],
354
+ minIn = true,
355
+ clipped = clip(convert(cur, "oklch", dest));
164
356
  if (deltaEOK(convert(clipped, dest, "oklab"), convert(oklch, "oklch", "oklab")) < JND) return clipped;
165
357
  while (max - min > EPS) {
166
- const chroma = (min + max) / 2; cur[1] = chroma; const inDest = convert(cur, "oklch", dest);
167
- if (minIn && inRange(inDest)) { min = chroma; continue; }
168
- clipped = clip(inDest); const e = deltaEOK(convert(clipped, dest, "oklab"), convert(cur, "oklch", "oklab"));
169
- if (e < JND) { if (JND - e < EPS) return clipped; minIn = false; min = chroma; } else max = chroma;
358
+ const chroma = (min + max) / 2;
359
+ cur[1] = chroma;
360
+ const inDest = convert(cur, "oklch", dest);
361
+ if (minIn && inRange(inDest)) {
362
+ min = chroma;
363
+ continue;
364
+ }
365
+ clipped = clip(inDest);
366
+ const e = deltaEOK(convert(clipped, dest, "oklab"), convert(cur, "oklch", "oklab"));
367
+ if (e < JND) {
368
+ if (JND - e < EPS) return clipped;
369
+ minIn = false;
370
+ min = chroma;
371
+ } else max = chroma;
170
372
  }
171
373
  return clipped;
172
374
  }
173
375
  // At a fixed hue OKLab→LMS collapses to p_i = L + C·d_i and LMS→XYZ→RGB to one matrix F, so the
174
376
  // two per-pixel helpers run trig- and allocation-free; identical to convert([L,C,hue], "oklch", gamut).
175
377
  const hueFold = (hue: number, gamut: Gamut) => {
176
- const h = hue * RAD, cos = Math.cos(h), sin = Math.sin(h);
378
+ const h = hue * RAD,
379
+ cos = Math.cos(h),
380
+ sin = Math.sin(h);
177
381
  const d = OKLAB_TO_LMS.map((row) => cos * row[1] + sin * row[2]);
178
- const F = mulMat(gamut === "p3" ? XYZ_TO_LIN_P3 : gamut === "rec2020" ? XYZ_TO_LIN_REC2020 : XYZ_TO_LIN_SRGB, LMS_TO_XYZ);
382
+ const F = mulMat(
383
+ gamut === "p3" ? XYZ_TO_LIN_P3 : gamut === "rec2020" ? XYZ_TO_LIN_REC2020 : XYZ_TO_LIN_SRGB,
384
+ LMS_TO_XYZ,
385
+ );
179
386
  return { d, F, gam: gamut === "rec2020" ? rec2020Gam : srgbGam };
180
387
  };
181
388
  /** OKLCH→RGB at a fixed hue for per-pixel rasterizing; `(L, C, out)` writes into `out`. */
182
389
  export function oklchToRgbFn(hue: number, gamut: Gamut) {
183
- const { d: [d0, d1, d2], F, gam } = hueFold(hue, gamut);
390
+ const {
391
+ d: [d0, d1, d2],
392
+ F,
393
+ gam,
394
+ } = hueFold(hue, gamut);
184
395
  return (L: number, C: number, out: number[]) => {
185
- const p0 = L + C * d0, p1 = L + C * d1, p2 = L + C * d2, c0 = p0 ** 3, c1 = p1 ** 3, c2 = p2 ** 3;
396
+ const p0 = L + C * d0,
397
+ p1 = L + C * d1,
398
+ p2 = L + C * d2,
399
+ c0 = p0 ** 3,
400
+ c1 = p1 ** 3,
401
+ c2 = p2 ** 3;
186
402
  out[0] = gam(F[0][0] * c0 + F[0][1] * c1 + F[0][2] * c2);
187
403
  out[1] = gam(F[1][0] * c0 + F[1][1] * c1 + F[1][2] * c2);
188
404
  out[2] = gam(F[2][0] * c0 + F[2][1] * c1 + F[2][2] * c2);
@@ -190,69 +406,173 @@ export function oklchToRgbFn(hue: number, gamut: Gamut) {
190
406
  }
191
407
  /** Fast in-gamut probe at a fixed hue: `(L, C) => inside?`. */
192
408
  export function oklchGamutProbe(hue: number, gamut: Gamut) {
193
- const toRgb = oklchToRgbFn(hue, gamut), rgb = [0, 0, 0], lo = -SLACK, hi = 1 + SLACK, inside = (v: number) => v >= lo && v <= hi;
194
- return (L: number, C: number) => { toRgb(L, C, rgb); return rgb.every(inside); };
409
+ const toRgb = oklchToRgbFn(hue, gamut),
410
+ rgb = [0, 0, 0],
411
+ lo = -SLACK,
412
+ hi = 1 + SLACK,
413
+ inside = (v: number) => v >= lo && v <= hi;
414
+ return (L: number, C: number) => {
415
+ toRgb(L, C, rgb);
416
+ return rgb.every(inside);
417
+ };
195
418
  }
196
419
  /** Bisect the chroma ceiling (highest in-gamut C) at lightness L for an `oklchGamutProbe`. */
197
420
  export function chromaCeil(probe: (L: number, C: number) => boolean, L: number, hi = 0.5) {
198
421
  if (!probe(L, 0)) return 0;
199
- let lo = 0; for (let k = 0; k < 16; k++) { const m = (lo + hi) / 2; probe(L, m) ? (lo = m) : (hi = m); }
422
+ let lo = 0;
423
+ for (let k = 0; k < 16; k++) {
424
+ const m = (lo + hi) / 2;
425
+ probe(L, m) ? (lo = m) : (hi = m);
426
+ }
200
427
  return lo;
201
428
  }
202
429
 
203
430
  // ── hex ──
204
431
  /** One 0–1 channel → a 2-digit hex byte (also the alpha suffix for #RRGGBBAA), upper-case as the readout shows it. */
205
- export const hexByte = (n: number) => Math.round(Math.min(1, Math.max(0, n)) * 255).toString(16).padStart(2, "0").toUpperCase();
432
+ export const hexByte = (n: number) =>
433
+ Math.round(Math.min(1, Math.max(0, n)) * 255)
434
+ .toString(16)
435
+ .padStart(2, "0")
436
+ .toUpperCase();
206
437
  const rgbToHex = (rgb: Vec3) => `#${hexByte(rgb[0])}${hexByte(rgb[1])}${hexByte(rgb[2])}`;
207
438
  export const oklchToHex = (L: number, C: number, H: number) => rgbToHex(toGamut([L, C, H], "srgb"));
208
439
  export function hexToOklch(hex: string): Vec3 {
209
440
  let h = String(hex).replace("#", "").trim();
210
- if (h.length === 3) h = h.split("").map((c) => c + c).join("");
441
+ if (h.length === 3)
442
+ h = h
443
+ .split("")
444
+ .map((c) => c + c)
445
+ .join("");
211
446
  if (h.length < 6) return [0.7, 0.1, 280];
212
- const rgb: Vec3 = [parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255];
447
+ const rgb: Vec3 = [
448
+ parseInt(h.slice(0, 2), 16) / 255,
449
+ parseInt(h.slice(2, 4), 16) / 255,
450
+ parseInt(h.slice(4, 6), 16) / 255,
451
+ ];
213
452
  return convert(rgb, "srgb", "oklch").map(num) as Vec3;
214
453
  }
215
454
 
216
455
  // ── mode model ──
217
- export const EDIT_MODES: readonly ColorMode[] = ["hex", "srgb", "css", "hsl", "hwb", "oklch", "oklab", "lch", "lab", "p3", "rec2020"];
218
- export const MODE_LABELS: Record<ColorMode, string> = { hex: "HEX", srgb: "RGB", css: "CSS", hsl: "HSL", hwb: "HWB", oklch: "OKLCH", oklab: "OKLab", lch: "LCH", lab: "Lab", p3: "P3", rec2020: "Rec2020" };
456
+ export const EDIT_MODES: readonly ColorMode[] = [
457
+ "hex",
458
+ "srgb",
459
+ "css",
460
+ "hsl",
461
+ "hwb",
462
+ "oklch",
463
+ "oklab",
464
+ "lch",
465
+ "lab",
466
+ "p3",
467
+ "rec2020",
468
+ ];
469
+ export const MODE_LABELS: Record<ColorMode, string> = {
470
+ hex: "HEX",
471
+ srgb: "RGB",
472
+ css: "CSS",
473
+ hsl: "HSL",
474
+ hwb: "HWB",
475
+ oklch: "OKLCH",
476
+ oklab: "OKLab",
477
+ lch: "LCH",
478
+ lab: "Lab",
479
+ p3: "P3",
480
+ rec2020: "Rec2020",
481
+ };
219
482
  const SRGB_BOUND: readonly ColorMode[] = ["srgb", "css", "hsl", "hwb", "hex"];
220
483
  export const showsGamutBoundary = (mode: ColorMode) => !SRGB_BOUND.includes(mode);
221
484
  const modeSpaceId = (mode: ColorMode): Space => (mode === "hex" || mode === "css" ? "srgb" : mode);
222
485
  // The CSS <color-interpolation-method> a gradient authored in each mode blends through.
223
486
  // Null-prototype, so a mode named "constructor" misses and falls to the default.
224
- const MODE_INTERP: Record<string, GradientInterpolation> = Object.assign(Object.create(null), { hex: "srgb", srgb: "srgb", css: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", p3: "display-p3", rec2020: "rec2020" });
487
+ const MODE_INTERP: Record<string, GradientInterpolation> = Object.assign(Object.create(null), {
488
+ hex: "srgb",
489
+ srgb: "srgb",
490
+ css: "srgb",
491
+ hsl: "hsl",
492
+ hwb: "hwb",
493
+ oklch: "oklch",
494
+ oklab: "oklab",
495
+ lch: "lch",
496
+ lab: "lab",
497
+ p3: "display-p3",
498
+ rec2020: "rec2020",
499
+ });
225
500
  export const modeInterpolation = (mode: string) => MODE_INTERP[mode] || "oklch";
226
501
  // The reverse: the mode that produced a stored gradient's blend; unknown falls to OKLCH.
227
- const INTERP_MODE: Record<string, ColorMode> = Object.assign(Object.create(null), { srgb: "srgb", hsl: "hsl", hwb: "hwb", oklch: "oklch", oklab: "oklab", lch: "lch", lab: "lab", "display-p3": "p3", rec2020: "rec2020" });
502
+ const INTERP_MODE: Record<string, ColorMode> = Object.assign(Object.create(null), {
503
+ srgb: "srgb",
504
+ hsl: "hsl",
505
+ hwb: "hwb",
506
+ oklch: "oklch",
507
+ oklab: "oklab",
508
+ lch: "lch",
509
+ lab: "lab",
510
+ "display-p3": "p3",
511
+ rec2020: "rec2020",
512
+ });
228
513
  export const interpolationMode = (interp: string) => INTERP_MODE[interp] || "oklch";
229
514
  /** The picker's chroma ceiling: the C field's max and the parser's cap on any chroma it reads. */
230
515
  export const MAX_CHROMA = 0.5;
231
516
  // Each triple is shared by two modes and frozen, so a one-mode tweak can't leak into its twin.
232
- const rgbTriple = (max: number, step: number, scale: number): readonly Channel[] => Object.freeze(["R", "G", "B"].map((k) => Object.freeze({ k, min: 0, max, step, scale })));
233
- const RGB_255 = rgbTriple(255, 1, 255), RGB_UNIT = rgbTriple(1, 0.01, 1);
517
+ const rgbTriple = (max: number, step: number, scale: number): readonly Channel[] =>
518
+ Object.freeze(["R", "G", "B"].map((k) => Object.freeze({ k, min: 0, max, step, scale })));
519
+ const RGB_255 = rgbTriple(255, 1, 255),
520
+ RGB_UNIT = rgbTriple(1, 0.01, 1);
234
521
  export const MODE_CHANNELS: Partial<Record<ColorMode, readonly Channel[]>> = {
235
- oklch: [{ k: "L", min: 0, max: 100, step: 1, scale: 100 }, { k: "C", min: 0, max: MAX_CHROMA, step: 0.01, scale: 1 }, { k: "H", min: 0, max: 360, step: 1, scale: 1 }],
236
- oklab: [{ k: "L", min: 0, max: 100, step: 1, scale: 100 }, { k: "a", min: -0.4, max: 0.4, step: 0.01, scale: 1 }, { k: "b", min: -0.4, max: 0.4, step: 0.01, scale: 1 }],
237
- lch: [{ k: "L", min: 0, max: 100, step: 1, scale: 1 }, { k: "C", min: 0, max: 150, step: 1, scale: 1 }, { k: "H", min: 0, max: 360, step: 1, scale: 1 }],
238
- lab: [{ k: "L", min: 0, max: 100, step: 1, scale: 1 }, { k: "a", min: -125, max: 125, step: 1, scale: 1 }, { k: "b", min: -125, max: 125, step: 1, scale: 1 }],
239
- srgb: RGB_255, css: RGB_255,
240
- hsl: [{ k: "H", min: 0, max: 360, step: 1, scale: 1 }, { k: "S", min: 0, max: 100, step: 1, scale: 1 }, { k: "L", min: 0, max: 100, step: 1, scale: 1 }],
241
- hwb: [{ k: "H", min: 0, max: 360, step: 1, scale: 1 }, { k: "W", min: 0, max: 100, step: 1, scale: 1 }, { k: "B", min: 0, max: 100, step: 1, scale: 1 }],
242
- p3: RGB_UNIT, rec2020: RGB_UNIT,
522
+ oklch: [
523
+ { k: "L", min: 0, max: 100, step: 1, scale: 100 },
524
+ { k: "C", min: 0, max: MAX_CHROMA, step: 0.01, scale: 1 },
525
+ { k: "H", min: 0, max: 360, step: 1, scale: 1 },
526
+ ],
527
+ oklab: [
528
+ { k: "L", min: 0, max: 100, step: 1, scale: 100 },
529
+ { k: "a", min: -0.4, max: 0.4, step: 0.01, scale: 1 },
530
+ { k: "b", min: -0.4, max: 0.4, step: 0.01, scale: 1 },
531
+ ],
532
+ lch: [
533
+ { k: "L", min: 0, max: 100, step: 1, scale: 1 },
534
+ { k: "C", min: 0, max: 150, step: 1, scale: 1 },
535
+ { k: "H", min: 0, max: 360, step: 1, scale: 1 },
536
+ ],
537
+ lab: [
538
+ { k: "L", min: 0, max: 100, step: 1, scale: 1 },
539
+ { k: "a", min: -125, max: 125, step: 1, scale: 1 },
540
+ { k: "b", min: -125, max: 125, step: 1, scale: 1 },
541
+ ],
542
+ srgb: RGB_255,
543
+ css: RGB_255,
544
+ hsl: [
545
+ { k: "H", min: 0, max: 360, step: 1, scale: 1 },
546
+ { k: "S", min: 0, max: 100, step: 1, scale: 1 },
547
+ { k: "L", min: 0, max: 100, step: 1, scale: 1 },
548
+ ],
549
+ hwb: [
550
+ { k: "H", min: 0, max: 360, step: 1, scale: 1 },
551
+ { k: "W", min: 0, max: 100, step: 1, scale: 1 },
552
+ { k: "B", min: 0, max: 100, step: 1, scale: 1 },
553
+ ],
554
+ p3: RGB_UNIT,
555
+ rec2020: RGB_UNIT,
243
556
  };
244
557
  const digitsFor = (step: number) => (step >= 1 ? 0 : step >= 0.1 ? 1 : step >= 0.01 ? 2 : 3);
245
558
 
246
559
  // Display coords for `mode` from canonical OKLCH. sRGB-bound modes read off the same
247
560
  // chroma-reduced sRGB hex uses, so every narrow-gamut mode agrees on the color.
248
561
  const modeCoords = (oklch: Vec3, sid: Space, mode: ColorMode): Vec3 =>
249
- sid === "oklch" ? [oklch[0], oklch[1], oklch[2]]
250
- : SRGB_BOUND.includes(mode) ? (sid === "srgb" ? toGamut(oklch, "srgb") : convert(toGamut(oklch, "srgb"), "srgb", sid))
251
- : convert(oklch, "oklch", sid);
562
+ sid === "oklch"
563
+ ? [oklch[0], oklch[1], oklch[2]]
564
+ : SRGB_BOUND.includes(mode)
565
+ ? sid === "srgb"
566
+ ? toGamut(oklch, "srgb")
567
+ : convert(toGamut(oklch, "srgb"), "srgb", sid)
568
+ : convert(oklch, "oklch", sid);
252
569
  /** Display-unit channel values for `mode` from canonical OKLCH. */
253
570
  export function channelValues(oklch: Vec3, mode: ColorMode) {
254
571
  const c = modeCoords(oklch, modeSpaceId(mode), mode);
255
- return MODE_CHANNELS[mode].map((ch, i) => { const v = num(c[i]) * ch.scale; return clamp(Math.abs(v) < 0.5 * ch.step ? 0 : v, ch.min, ch.max); });
572
+ return MODE_CHANNELS[mode].map((ch, i) => {
573
+ const v = num(c[i]) * ch.scale;
574
+ return clamp(Math.abs(v) < 0.5 * ch.step ? 0 : v, ch.min, ch.max);
575
+ });
256
576
  }
257
577
  /** New OKLCH with channel `index` of `mode` set to `displayValue`. */
258
578
  export function withChannel(oklch: Vec3, mode: ColorMode, index: number, displayValue: number): Vec3 {
@@ -273,28 +593,55 @@ export function gamutLabel(oklch: Vec3, mode: ColorMode) {
273
593
  }
274
594
  // The three channels in `mode`'s own syntax: commas for legacy css, a % on lightness or on the last two of hsl/hwb.
275
595
  const joinChannels = (mode: ColorMode, s: (i: number) => string) =>
276
- mode === "css" ? `${s(0)}, ${s(1)}, ${s(2)}`
277
- : mode === "oklch" || mode === "oklab" || mode === "lch" || mode === "lab" ? `${s(0)}% ${s(1)} ${s(2)}`
278
- : mode === "hsl" || mode === "hwb" ? `${s(0)} ${s(1)}% ${s(2)}%`
279
- : `${s(0)} ${s(1)} ${s(2)}`; // srgb / p3 / rec2020
596
+ mode === "css"
597
+ ? `${s(0)}, ${s(1)}, ${s(2)}`
598
+ : mode === "oklch" || mode === "oklab" || mode === "lch" || mode === "lab"
599
+ ? `${s(0)}% ${s(1)} ${s(2)}`
600
+ : mode === "hsl" || mode === "hwb"
601
+ ? `${s(0)} ${s(1)}% ${s(2)}%`
602
+ : `${s(0)} ${s(1)} ${s(2)}`; // srgb / p3 / rec2020
280
603
  /** The collapsed-row value string for `mode` (bare channels w/ units, or hex). */
281
604
  export function readout(oklch: Vec3, mode: ColorMode) {
282
605
  if (mode === "hex") return oklchToHex(oklch[0], oklch[1], oklch[2]);
283
- const chans = MODE_CHANNELS[mode], v = channelValues(oklch, mode);
284
- return joinChannels(mode, (i) => { const t = v[i].toFixed(digitsFor(chans[i].step)); return chans[i].k === "H" && +t === 360 ? (0).toFixed(digitsFor(chans[i].step)) : t; }); // 360 is 0; only 0 reads back as itself
606
+ const chans = MODE_CHANNELS[mode],
607
+ v = channelValues(oklch, mode);
608
+ // 360 is 0; only 0 reads back as itself
609
+ return joinChannels(mode, (i) => {
610
+ const t = v[i].toFixed(digitsFor(chans[i].step));
611
+ return chans[i].k === "H" && +t === 360 ? (0).toFixed(digitsFor(chans[i].step)) : t;
612
+ });
285
613
  }
286
614
  // css is legacy comma rgba with an explicit alpha slot; the rest take modern space syntax.
287
615
  const wrapCss = (t: string, mode: ColorMode) =>
288
- mode === "hex" ? t : mode === "srgb" ? `rgb(${t})` : mode === "css" ? `rgba(${t}, 1)` :
289
- mode === "hsl" ? `hsl(${t})` : mode === "hwb" ? `hwb(${t})` :
290
- mode === "p3" ? `color(display-p3 ${t})` : mode === "rec2020" ? `color(rec2020 ${t})` : `${mode}(${t})`;
291
- // The value's channels, a digit finer than the readout (L 63.2%, C 0.257, H 29.2): rounded to the readout, 98% of colors came
292
- // back different (#939796's faint green read as a neutral gray). A powerless hue is `none`, as CSS Color 4 writes it: a
293
- // float-noise hue on white (180, cyan) or a gray bent every oklch gradient and color-mix() through it. HSL's black and
294
- // white are written S 0 and hue none: S divides by min(L, 1 - L), so next to either end it read noise as 100% and a hue.
616
+ mode === "hex"
617
+ ? t
618
+ : mode === "srgb"
619
+ ? `rgb(${t})`
620
+ : mode === "css"
621
+ ? `rgba(${t}, 1)`
622
+ : mode === "hsl"
623
+ ? `hsl(${t})`
624
+ : mode === "hwb"
625
+ ? `hwb(${t})`
626
+ : mode === "p3"
627
+ ? `color(display-p3 ${t})`
628
+ : mode === "rec2020"
629
+ ? `color(rec2020 ${t})`
630
+ : `${mode}(${t})`;
631
+ // The value's channels, a digit finer than the readout (L 63.2%, C 0.257, H 29.2): rounded to the readout, 98% of
632
+ // colors came back different (#939796's faint green read as a neutral gray). A powerless hue is `none`, as CSS Color 4
633
+ // writes it: a float-noise hue on white (180, cyan) or a gray bent every oklch gradient and color-mix() through it.
634
+ // HSL's black and white are written S 0 and hue none: S divides by min(L, 1 - L), so next to either end it read noise
635
+ // as 100% and a hue.
295
636
  function precise(oklch: Vec3, mode: ColorMode) {
296
- const chans = MODE_CHANNELS[mode], c = modeCoords(oklch, modeSpaceId(mode), mode);
297
- const v = chans.map((ch, i) => { const d = digitsFor(ch.step) + 1, x = +clamp(num(c[i]) * ch.scale, ch.min, ch.max).toFixed(d); return ch.k === "H" && x === 360 ? 0 : x + 0; }); // + 0: no -0
637
+ const chans = MODE_CHANNELS[mode],
638
+ c = modeCoords(oklch, modeSpaceId(mode), mode);
639
+ // + 0: no -0
640
+ const v = chans.map((ch, i) => {
641
+ const d = digitsFor(ch.step) + 1,
642
+ x = +clamp(num(c[i]) * ch.scale, ch.min, ch.max).toFixed(d);
643
+ return ch.k === "H" && x === 360 ? 0 : x + 0;
644
+ });
298
645
  if (mode === "hsl" && (v[2] === 0 || v[2] === 100)) v[1] = 0;
299
646
  const gray = mode === "oklch" || mode === "lch" || mode === "hsl" ? v[1] === 0 : mode === "hwb" && v[1] + v[2] >= 100;
300
647
  return joinChannels(mode, (i) => (gray && chans[i].k === "H" ? "none" : String(v[i])));
@@ -306,5 +653,6 @@ export function serialize(oklch: Vec3, mode: ColorMode, alpha: number) {
306
653
  const t = precise(oklch, mode);
307
654
  if (a >= 0.999) return wrapCss(t, mode);
308
655
  const av = +a.toFixed(3);
309
- return mode === "css" ? `rgba(${t}, ${av})` : wrapCss(`${t} / ${av}`, mode); // comma alpha for legacy rgba, slash otherwise
656
+ // comma alpha for legacy rgba, slash otherwise
657
+ return mode === "css" ? `rgba(${t}, ${av})` : wrapCss(`${t} / ${av}`, mode);
310
658
  }