color-value-tools 1.1.12 → 1.2.1

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/dist/cjs/index.js CHANGED
@@ -130,15 +130,24 @@ const NAMED_COLORS = {
130
130
  springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
131
131
  tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff',
132
132
  whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
133
- // Special CSS keywords
134
- transparent: 'transparent', currentcolor: 'currentcolor', inherit: 'inherit', initial: 'initial', unset: 'unset',
133
+ // 'transparent' is the only CSS-wide keyword with an actual fixed color (fully
134
+ // transparent black) — normalizeColor() special-cases it before ever reaching
135
+ // this table (see the `str === 'transparent'` check), so this entry only
136
+ // matters for getColorType()/toNearestNamedColor(), which read the table
137
+ // directly. 'currentcolor'/'inherit'/'initial'/'unset' are deliberately NOT
138
+ // listed here: none of them has a color value of their own (they only resolve
139
+ // to one given surrounding DOM/CSSOM context this library never has access
140
+ // to), so treating them as resolvable named colors would mean fabricating an
141
+ // arbitrary RGB value with no way for a caller to tell it apart from a real
142
+ // one — see normalizeColor()'s `type: 'unknown'` fallback for these instead.
143
+ transparent: 'transparent',
135
144
  };
136
145
  function isCssVariable(value) {
137
146
  return value.trim().startsWith('var(--');
138
147
  }
139
148
  function isHexColor(value) {
140
149
  const trimmed = value.trim();
141
- return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6})$/.test(trimmed);
150
+ return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed);
142
151
  }
143
152
  function isOklchColor(value) {
144
153
  return /^\s*oklcha?\s*\(/i.test(value.trim());
@@ -479,11 +488,18 @@ function rgbaStringToRgba(str) {
479
488
  const parts = m[1].split(/,\s*/).map(p => p.trim());
480
489
  if (parts.length < 3)
481
490
  return null;
482
- const parseChannel = (v) => v.endsWith('%') ? Math.round(parseFloat(v) * 2.55) : Math.round(parseFloat(v));
491
+ // Clamp channels to a valid byte and alpha to [0,1] — an out-of-range input
492
+ // like "rgb(300, -20, 0)" or "rgba(0,0,0,2)" is invalid CSS, but previously
493
+ // passed straight through unclamped, which could even corrupt downstream hex
494
+ // output (a negative channel stringifies with a leading "-" via toString(16)).
495
+ const parseChannel = (v) => {
496
+ const n = v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v);
497
+ return Math.round(Math.max(0, Math.min(255, n)));
498
+ };
483
499
  const r = parseChannel(parts[0]);
484
500
  const g = parseChannel(parts[1]);
485
501
  const b = parseChannel(parts[2]);
486
- const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
502
+ const a = parts[3] !== undefined ? Math.max(0, Math.min(1, parseFloat(parts[3]))) : 1;
487
503
  return { r, g, b, a };
488
504
  }
489
505
  function rgbToHsl({ r, g, b }) {
@@ -740,6 +756,16 @@ function normalizeColor(input) {
740
756
  g = srgb.g;
741
757
  b = srgb.b;
742
758
  }
759
+ else if (parsed.space === 'srgb-linear') {
760
+ // srgb-linear components are linear-light, not gamma-encoded — unlike
761
+ // plain srgb below, they need the linear-to-sRGB transfer function
762
+ // applied before scaling to a 0-255 byte. Without this, only the 0/1
763
+ // extremes happened to come out correct; any mid-range value (e.g. 0.5)
764
+ // converted as if it were already gamma-encoded, which is wrong.
765
+ r = Math.round(linearChanToSrgb(p3r) * 255);
766
+ g = Math.round(linearChanToSrgb(p3g) * 255);
767
+ b = Math.round(linearChanToSrgb(p3b) * 255);
768
+ }
743
769
  else {
744
770
  r = Math.round(Math.max(0, Math.min(255, p3r * 255)));
745
771
  g = Math.round(Math.max(0, Math.min(255, p3g * 255)));
@@ -778,10 +804,16 @@ function tetradic(color) {
778
804
  return [hex, rotateHue(hex, 90), rotateHue(hex, 180), rotateHue(hex, 270)];
779
805
  }
780
806
  function colorShades(color, steps = 9) {
781
- var _a, _b;
807
+ var _a, _b, _c;
782
808
  const n = normalizeColor(color);
783
809
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
784
810
  const s = (_b = n.s) !== null && _b !== void 0 ? _b : 0;
811
+ // steps - 1 === 0 below would otherwise divide by zero (NaN lightness, then
812
+ // a literal "#NaNNaNNaN" hex string) — a single shade is just the color's
813
+ // own lightness, same idea as interpolateColors() returning the midpoint
814
+ // for a 2-color, 1-step request.
815
+ if (steps <= 1)
816
+ return steps === 1 ? [hslToHex(h, s, (_c = n.l) !== null && _c !== void 0 ? _c : 50)] : [];
785
817
  const result = [];
786
818
  for (let i = 0; i < steps; i++) {
787
819
  const l = Math.round(100 - (i / (steps - 1)) * 100);
@@ -790,10 +822,13 @@ function colorShades(color, steps = 9) {
790
822
  return result;
791
823
  }
792
824
  function monochromatic(color, steps = 5) {
793
- var _a, _b;
825
+ var _a, _b, _c;
794
826
  const n = normalizeColor(color);
795
827
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
796
828
  const l = (_b = n.l) !== null && _b !== void 0 ? _b : 50;
829
+ // Same steps === 1 divide-by-zero guard as colorShades() above.
830
+ if (steps <= 1)
831
+ return steps === 1 ? [hslToHex(h, (_c = n.s) !== null && _c !== void 0 ? _c : 0, l)] : [];
797
832
  const result = [];
798
833
  for (let i = 0; i < steps; i++) {
799
834
  const s = Math.round((i / (steps - 1)) * 100);
@@ -1215,6 +1250,12 @@ function toNearestNamedColor(color) {
1215
1250
  let bestName = 'black';
1216
1251
  let bestDist = Infinity;
1217
1252
  for (const [name, hex] of Object.entries(NAMED_COLORS)) {
1253
+ // Skip any non-hex entry (currently just a defensive guard — 'transparent'
1254
+ // is the only such value left in the table, and it has no real RGB to
1255
+ // compare against; hexToRgb() would otherwise silently fall back to
1256
+ // normalizeHex()'s placeholder color for it).
1257
+ if (hex[0] !== '#')
1258
+ continue;
1218
1259
  const [nr, ng, nb] = hexToRgb(hex);
1219
1260
  const dist = Math.sqrt(Math.pow(r - nr, 2) + Math.pow(g - ng, 2) + Math.pow(b - nb, 2));
1220
1261
  if (dist < bestDist) {
@@ -1324,8 +1365,13 @@ function isReadableOnBackground(textColor, background, options) {
1324
1365
  const level = (_a = options === null || options === void 0 ? void 0 : options.level) !== null && _a !== void 0 ? _a : 'AA';
1325
1366
  const large = (_b = options === null || options === void 0 ? void 0 : options.largeText) !== null && _b !== void 0 ? _b : false;
1326
1367
  const minRequired = level === 'AAA' ? (large ? 4.5 : 7) : (large ? 3 : 4.5);
1368
+ // The specific effective background color that minRatio was actually
1369
+ // computed against — wcagLevel below is derived from this same color
1370
+ // (never a hardcoded '#ffffff') so it can't disagree with minRatio/readable.
1327
1371
  let minRatio;
1372
+ let effectiveBackground;
1328
1373
  if (typeof background === 'string') {
1374
+ effectiveBackground = background;
1329
1375
  minRatio = contrastRatio(textColor, background);
1330
1376
  }
1331
1377
  else if (background.type === 'semi-transparent') {
@@ -1336,13 +1382,24 @@ function isReadableOnBackground(textColor, background, options) {
1336
1382
  const cr = Math.round(((_e = fg.r) !== null && _e !== void 0 ? _e : 0) * alpha + ((_f = bg.r) !== null && _f !== void 0 ? _f : 255) * (1 - alpha));
1337
1383
  const cg = Math.round(((_g = fg.g) !== null && _g !== void 0 ? _g : 0) * alpha + ((_h = bg.g) !== null && _h !== void 0 ? _h : 255) * (1 - alpha));
1338
1384
  const cb = Math.round(((_j = fg.b) !== null && _j !== void 0 ? _j : 0) * alpha + ((_k = bg.b) !== null && _k !== void 0 ? _k : 255) * (1 - alpha));
1339
- minRatio = contrastRatio(textColor, rgbToHex({ r: cr, g: cg, b: cb }));
1385
+ effectiveBackground = rgbToHex({ r: cr, g: cg, b: cb });
1386
+ minRatio = contrastRatio(textColor, effectiveBackground);
1340
1387
  }
1341
1388
  else {
1342
- const ratios = background.stops.map(stop => contrastRatio(textColor, stop));
1343
- minRatio = Math.min(...ratios);
1389
+ // The worst-case stop (lowest ratio) drives both minRatio and wcagLevel.
1390
+ let worstStop = background.stops[0];
1391
+ let worstRatio = Infinity;
1392
+ for (const stop of background.stops) {
1393
+ const ratio = contrastRatio(textColor, stop);
1394
+ if (ratio < worstRatio) {
1395
+ worstRatio = ratio;
1396
+ worstStop = stop;
1397
+ }
1398
+ }
1399
+ effectiveBackground = worstStop;
1400
+ minRatio = worstRatio;
1344
1401
  }
1345
- const wLevel = wcagLevel(textColor, typeof background === 'string' ? background : '#ffffff');
1402
+ const wLevel = wcagLevel(textColor, effectiveBackground);
1346
1403
  return { readable: minRatio >= minRequired, minContrastRatio: +minRatio.toFixed(2), wcagLevel: wLevel };
1347
1404
  }
1348
1405
  function bestContrastPalette(background, palettes, options) {
@@ -130,15 +130,24 @@ const NAMED_COLORS = {
130
130
  springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
131
131
  tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff',
132
132
  whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
133
- // Special CSS keywords
134
- transparent: 'transparent', currentcolor: 'currentcolor', inherit: 'inherit', initial: 'initial', unset: 'unset',
133
+ // 'transparent' is the only CSS-wide keyword with an actual fixed color (fully
134
+ // transparent black) — normalizeColor() special-cases it before ever reaching
135
+ // this table (see the `str === 'transparent'` check), so this entry only
136
+ // matters for getColorType()/toNearestNamedColor(), which read the table
137
+ // directly. 'currentcolor'/'inherit'/'initial'/'unset' are deliberately NOT
138
+ // listed here: none of them has a color value of their own (they only resolve
139
+ // to one given surrounding DOM/CSSOM context this library never has access
140
+ // to), so treating them as resolvable named colors would mean fabricating an
141
+ // arbitrary RGB value with no way for a caller to tell it apart from a real
142
+ // one — see normalizeColor()'s `type: 'unknown'` fallback for these instead.
143
+ transparent: 'transparent',
135
144
  };
136
145
  function isCssVariable(value) {
137
146
  return value.trim().startsWith('var(--');
138
147
  }
139
148
  function isHexColor(value) {
140
149
  const trimmed = value.trim();
141
- return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6})$/.test(trimmed);
150
+ return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed);
142
151
  }
143
152
  function isOklchColor(value) {
144
153
  return /^\s*oklcha?\s*\(/i.test(value.trim());
@@ -479,11 +488,18 @@ function rgbaStringToRgba(str) {
479
488
  const parts = m[1].split(/,\s*/).map(p => p.trim());
480
489
  if (parts.length < 3)
481
490
  return null;
482
- const parseChannel = (v) => v.endsWith('%') ? Math.round(parseFloat(v) * 2.55) : Math.round(parseFloat(v));
491
+ // Clamp channels to a valid byte and alpha to [0,1] — an out-of-range input
492
+ // like "rgb(300, -20, 0)" or "rgba(0,0,0,2)" is invalid CSS, but previously
493
+ // passed straight through unclamped, which could even corrupt downstream hex
494
+ // output (a negative channel stringifies with a leading "-" via toString(16)).
495
+ const parseChannel = (v) => {
496
+ const n = v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v);
497
+ return Math.round(Math.max(0, Math.min(255, n)));
498
+ };
483
499
  const r = parseChannel(parts[0]);
484
500
  const g = parseChannel(parts[1]);
485
501
  const b = parseChannel(parts[2]);
486
- const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
502
+ const a = parts[3] !== undefined ? Math.max(0, Math.min(1, parseFloat(parts[3]))) : 1;
487
503
  return { r, g, b, a };
488
504
  }
489
505
  function rgbToHsl({ r, g, b }) {
@@ -740,6 +756,16 @@ function normalizeColor(input) {
740
756
  g = srgb.g;
741
757
  b = srgb.b;
742
758
  }
759
+ else if (parsed.space === 'srgb-linear') {
760
+ // srgb-linear components are linear-light, not gamma-encoded — unlike
761
+ // plain srgb below, they need the linear-to-sRGB transfer function
762
+ // applied before scaling to a 0-255 byte. Without this, only the 0/1
763
+ // extremes happened to come out correct; any mid-range value (e.g. 0.5)
764
+ // converted as if it were already gamma-encoded, which is wrong.
765
+ r = Math.round(linearChanToSrgb(p3r) * 255);
766
+ g = Math.round(linearChanToSrgb(p3g) * 255);
767
+ b = Math.round(linearChanToSrgb(p3b) * 255);
768
+ }
743
769
  else {
744
770
  r = Math.round(Math.max(0, Math.min(255, p3r * 255)));
745
771
  g = Math.round(Math.max(0, Math.min(255, p3g * 255)));
@@ -778,10 +804,16 @@ function tetradic(color) {
778
804
  return [hex, rotateHue(hex, 90), rotateHue(hex, 180), rotateHue(hex, 270)];
779
805
  }
780
806
  function colorShades(color, steps = 9) {
781
- var _a, _b;
807
+ var _a, _b, _c;
782
808
  const n = normalizeColor(color);
783
809
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
784
810
  const s = (_b = n.s) !== null && _b !== void 0 ? _b : 0;
811
+ // steps - 1 === 0 below would otherwise divide by zero (NaN lightness, then
812
+ // a literal "#NaNNaNNaN" hex string) — a single shade is just the color's
813
+ // own lightness, same idea as interpolateColors() returning the midpoint
814
+ // for a 2-color, 1-step request.
815
+ if (steps <= 1)
816
+ return steps === 1 ? [hslToHex(h, s, (_c = n.l) !== null && _c !== void 0 ? _c : 50)] : [];
785
817
  const result = [];
786
818
  for (let i = 0; i < steps; i++) {
787
819
  const l = Math.round(100 - (i / (steps - 1)) * 100);
@@ -790,10 +822,13 @@ function colorShades(color, steps = 9) {
790
822
  return result;
791
823
  }
792
824
  function monochromatic(color, steps = 5) {
793
- var _a, _b;
825
+ var _a, _b, _c;
794
826
  const n = normalizeColor(color);
795
827
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
796
828
  const l = (_b = n.l) !== null && _b !== void 0 ? _b : 50;
829
+ // Same steps === 1 divide-by-zero guard as colorShades() above.
830
+ if (steps <= 1)
831
+ return steps === 1 ? [hslToHex(h, (_c = n.s) !== null && _c !== void 0 ? _c : 0, l)] : [];
797
832
  const result = [];
798
833
  for (let i = 0; i < steps; i++) {
799
834
  const s = Math.round((i / (steps - 1)) * 100);
@@ -1215,6 +1250,12 @@ function toNearestNamedColor(color) {
1215
1250
  let bestName = 'black';
1216
1251
  let bestDist = Infinity;
1217
1252
  for (const [name, hex] of Object.entries(NAMED_COLORS)) {
1253
+ // Skip any non-hex entry (currently just a defensive guard — 'transparent'
1254
+ // is the only such value left in the table, and it has no real RGB to
1255
+ // compare against; hexToRgb() would otherwise silently fall back to
1256
+ // normalizeHex()'s placeholder color for it).
1257
+ if (hex[0] !== '#')
1258
+ continue;
1218
1259
  const [nr, ng, nb] = hexToRgb(hex);
1219
1260
  const dist = Math.sqrt(Math.pow(r - nr, 2) + Math.pow(g - ng, 2) + Math.pow(b - nb, 2));
1220
1261
  if (dist < bestDist) {
@@ -1324,8 +1365,13 @@ function isReadableOnBackground(textColor, background, options) {
1324
1365
  const level = (_a = options === null || options === void 0 ? void 0 : options.level) !== null && _a !== void 0 ? _a : 'AA';
1325
1366
  const large = (_b = options === null || options === void 0 ? void 0 : options.largeText) !== null && _b !== void 0 ? _b : false;
1326
1367
  const minRequired = level === 'AAA' ? (large ? 4.5 : 7) : (large ? 3 : 4.5);
1368
+ // The specific effective background color that minRatio was actually
1369
+ // computed against — wcagLevel below is derived from this same color
1370
+ // (never a hardcoded '#ffffff') so it can't disagree with minRatio/readable.
1327
1371
  let minRatio;
1372
+ let effectiveBackground;
1328
1373
  if (typeof background === 'string') {
1374
+ effectiveBackground = background;
1329
1375
  minRatio = contrastRatio(textColor, background);
1330
1376
  }
1331
1377
  else if (background.type === 'semi-transparent') {
@@ -1336,13 +1382,24 @@ function isReadableOnBackground(textColor, background, options) {
1336
1382
  const cr = Math.round(((_e = fg.r) !== null && _e !== void 0 ? _e : 0) * alpha + ((_f = bg.r) !== null && _f !== void 0 ? _f : 255) * (1 - alpha));
1337
1383
  const cg = Math.round(((_g = fg.g) !== null && _g !== void 0 ? _g : 0) * alpha + ((_h = bg.g) !== null && _h !== void 0 ? _h : 255) * (1 - alpha));
1338
1384
  const cb = Math.round(((_j = fg.b) !== null && _j !== void 0 ? _j : 0) * alpha + ((_k = bg.b) !== null && _k !== void 0 ? _k : 255) * (1 - alpha));
1339
- minRatio = contrastRatio(textColor, rgbToHex({ r: cr, g: cg, b: cb }));
1385
+ effectiveBackground = rgbToHex({ r: cr, g: cg, b: cb });
1386
+ minRatio = contrastRatio(textColor, effectiveBackground);
1340
1387
  }
1341
1388
  else {
1342
- const ratios = background.stops.map(stop => contrastRatio(textColor, stop));
1343
- minRatio = Math.min(...ratios);
1389
+ // The worst-case stop (lowest ratio) drives both minRatio and wcagLevel.
1390
+ let worstStop = background.stops[0];
1391
+ let worstRatio = Infinity;
1392
+ for (const stop of background.stops) {
1393
+ const ratio = contrastRatio(textColor, stop);
1394
+ if (ratio < worstRatio) {
1395
+ worstRatio = ratio;
1396
+ worstStop = stop;
1397
+ }
1398
+ }
1399
+ effectiveBackground = worstStop;
1400
+ minRatio = worstRatio;
1344
1401
  }
1345
- const wLevel = wcagLevel(textColor, typeof background === 'string' ? background : '#ffffff');
1402
+ const wLevel = wcagLevel(textColor, effectiveBackground);
1346
1403
  return { readable: minRatio >= minRequired, minContrastRatio: +minRatio.toFixed(2), wcagLevel: wLevel };
1347
1404
  }
1348
1405
  function bestContrastPalette(background, palettes, options) {
package/dist/esm/index.js CHANGED
@@ -30,15 +30,24 @@ const NAMED_COLORS = {
30
30
  springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
31
31
  tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff',
32
32
  whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
33
- // Special CSS keywords
34
- transparent: 'transparent', currentcolor: 'currentcolor', inherit: 'inherit', initial: 'initial', unset: 'unset',
33
+ // 'transparent' is the only CSS-wide keyword with an actual fixed color (fully
34
+ // transparent black) — normalizeColor() special-cases it before ever reaching
35
+ // this table (see the `str === 'transparent'` check), so this entry only
36
+ // matters for getColorType()/toNearestNamedColor(), which read the table
37
+ // directly. 'currentcolor'/'inherit'/'initial'/'unset' are deliberately NOT
38
+ // listed here: none of them has a color value of their own (they only resolve
39
+ // to one given surrounding DOM/CSSOM context this library never has access
40
+ // to), so treating them as resolvable named colors would mean fabricating an
41
+ // arbitrary RGB value with no way for a caller to tell it apart from a real
42
+ // one — see normalizeColor()'s `type: 'unknown'` fallback for these instead.
43
+ transparent: 'transparent',
35
44
  };
36
45
  export function isCssVariable(value) {
37
46
  return value.trim().startsWith('var(--');
38
47
  }
39
48
  export function isHexColor(value) {
40
49
  const trimmed = value.trim();
41
- return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6})$/.test(trimmed);
50
+ return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed);
42
51
  }
43
52
  export function isOklchColor(value) {
44
53
  return /^\s*oklcha?\s*\(/i.test(value.trim());
@@ -379,11 +388,18 @@ export function rgbaStringToRgba(str) {
379
388
  const parts = m[1].split(/,\s*/).map(p => p.trim());
380
389
  if (parts.length < 3)
381
390
  return null;
382
- const parseChannel = (v) => v.endsWith('%') ? Math.round(parseFloat(v) * 2.55) : Math.round(parseFloat(v));
391
+ // Clamp channels to a valid byte and alpha to [0,1] — an out-of-range input
392
+ // like "rgb(300, -20, 0)" or "rgba(0,0,0,2)" is invalid CSS, but previously
393
+ // passed straight through unclamped, which could even corrupt downstream hex
394
+ // output (a negative channel stringifies with a leading "-" via toString(16)).
395
+ const parseChannel = (v) => {
396
+ const n = v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v);
397
+ return Math.round(Math.max(0, Math.min(255, n)));
398
+ };
383
399
  const r = parseChannel(parts[0]);
384
400
  const g = parseChannel(parts[1]);
385
401
  const b = parseChannel(parts[2]);
386
- const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
402
+ const a = parts[3] !== undefined ? Math.max(0, Math.min(1, parseFloat(parts[3]))) : 1;
387
403
  return { r, g, b, a };
388
404
  }
389
405
  export function rgbToHsl({ r, g, b }) {
@@ -640,6 +656,16 @@ export function normalizeColor(input) {
640
656
  g = srgb.g;
641
657
  b = srgb.b;
642
658
  }
659
+ else if (parsed.space === 'srgb-linear') {
660
+ // srgb-linear components are linear-light, not gamma-encoded — unlike
661
+ // plain srgb below, they need the linear-to-sRGB transfer function
662
+ // applied before scaling to a 0-255 byte. Without this, only the 0/1
663
+ // extremes happened to come out correct; any mid-range value (e.g. 0.5)
664
+ // converted as if it were already gamma-encoded, which is wrong.
665
+ r = Math.round(linearChanToSrgb(p3r) * 255);
666
+ g = Math.round(linearChanToSrgb(p3g) * 255);
667
+ b = Math.round(linearChanToSrgb(p3b) * 255);
668
+ }
643
669
  else {
644
670
  r = Math.round(Math.max(0, Math.min(255, p3r * 255)));
645
671
  g = Math.round(Math.max(0, Math.min(255, p3g * 255)));
@@ -678,10 +704,16 @@ export function tetradic(color) {
678
704
  return [hex, rotateHue(hex, 90), rotateHue(hex, 180), rotateHue(hex, 270)];
679
705
  }
680
706
  export function colorShades(color, steps = 9) {
681
- var _a, _b;
707
+ var _a, _b, _c;
682
708
  const n = normalizeColor(color);
683
709
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
684
710
  const s = (_b = n.s) !== null && _b !== void 0 ? _b : 0;
711
+ // steps - 1 === 0 below would otherwise divide by zero (NaN lightness, then
712
+ // a literal "#NaNNaNNaN" hex string) — a single shade is just the color's
713
+ // own lightness, same idea as interpolateColors() returning the midpoint
714
+ // for a 2-color, 1-step request.
715
+ if (steps <= 1)
716
+ return steps === 1 ? [hslToHex(h, s, (_c = n.l) !== null && _c !== void 0 ? _c : 50)] : [];
685
717
  const result = [];
686
718
  for (let i = 0; i < steps; i++) {
687
719
  const l = Math.round(100 - (i / (steps - 1)) * 100);
@@ -690,10 +722,13 @@ export function colorShades(color, steps = 9) {
690
722
  return result;
691
723
  }
692
724
  export function monochromatic(color, steps = 5) {
693
- var _a, _b;
725
+ var _a, _b, _c;
694
726
  const n = normalizeColor(color);
695
727
  const h = (_a = n.h) !== null && _a !== void 0 ? _a : 0;
696
728
  const l = (_b = n.l) !== null && _b !== void 0 ? _b : 50;
729
+ // Same steps === 1 divide-by-zero guard as colorShades() above.
730
+ if (steps <= 1)
731
+ return steps === 1 ? [hslToHex(h, (_c = n.s) !== null && _c !== void 0 ? _c : 0, l)] : [];
697
732
  const result = [];
698
733
  for (let i = 0; i < steps; i++) {
699
734
  const s = Math.round((i / (steps - 1)) * 100);
@@ -1115,6 +1150,12 @@ export function toNearestNamedColor(color) {
1115
1150
  let bestName = 'black';
1116
1151
  let bestDist = Infinity;
1117
1152
  for (const [name, hex] of Object.entries(NAMED_COLORS)) {
1153
+ // Skip any non-hex entry (currently just a defensive guard — 'transparent'
1154
+ // is the only such value left in the table, and it has no real RGB to
1155
+ // compare against; hexToRgb() would otherwise silently fall back to
1156
+ // normalizeHex()'s placeholder color for it).
1157
+ if (hex[0] !== '#')
1158
+ continue;
1118
1159
  const [nr, ng, nb] = hexToRgb(hex);
1119
1160
  const dist = Math.sqrt(Math.pow(r - nr, 2) + Math.pow(g - ng, 2) + Math.pow(b - nb, 2));
1120
1161
  if (dist < bestDist) {
@@ -1224,8 +1265,13 @@ export function isReadableOnBackground(textColor, background, options) {
1224
1265
  const level = (_a = options === null || options === void 0 ? void 0 : options.level) !== null && _a !== void 0 ? _a : 'AA';
1225
1266
  const large = (_b = options === null || options === void 0 ? void 0 : options.largeText) !== null && _b !== void 0 ? _b : false;
1226
1267
  const minRequired = level === 'AAA' ? (large ? 4.5 : 7) : (large ? 3 : 4.5);
1268
+ // The specific effective background color that minRatio was actually
1269
+ // computed against — wcagLevel below is derived from this same color
1270
+ // (never a hardcoded '#ffffff') so it can't disagree with minRatio/readable.
1227
1271
  let minRatio;
1272
+ let effectiveBackground;
1228
1273
  if (typeof background === 'string') {
1274
+ effectiveBackground = background;
1229
1275
  minRatio = contrastRatio(textColor, background);
1230
1276
  }
1231
1277
  else if (background.type === 'semi-transparent') {
@@ -1236,13 +1282,24 @@ export function isReadableOnBackground(textColor, background, options) {
1236
1282
  const cr = Math.round(((_e = fg.r) !== null && _e !== void 0 ? _e : 0) * alpha + ((_f = bg.r) !== null && _f !== void 0 ? _f : 255) * (1 - alpha));
1237
1283
  const cg = Math.round(((_g = fg.g) !== null && _g !== void 0 ? _g : 0) * alpha + ((_h = bg.g) !== null && _h !== void 0 ? _h : 255) * (1 - alpha));
1238
1284
  const cb = Math.round(((_j = fg.b) !== null && _j !== void 0 ? _j : 0) * alpha + ((_k = bg.b) !== null && _k !== void 0 ? _k : 255) * (1 - alpha));
1239
- minRatio = contrastRatio(textColor, rgbToHex({ r: cr, g: cg, b: cb }));
1285
+ effectiveBackground = rgbToHex({ r: cr, g: cg, b: cb });
1286
+ minRatio = contrastRatio(textColor, effectiveBackground);
1240
1287
  }
1241
1288
  else {
1242
- const ratios = background.stops.map(stop => contrastRatio(textColor, stop));
1243
- minRatio = Math.min(...ratios);
1289
+ // The worst-case stop (lowest ratio) drives both minRatio and wcagLevel.
1290
+ let worstStop = background.stops[0];
1291
+ let worstRatio = Infinity;
1292
+ for (const stop of background.stops) {
1293
+ const ratio = contrastRatio(textColor, stop);
1294
+ if (ratio < worstRatio) {
1295
+ worstRatio = ratio;
1296
+ worstStop = stop;
1297
+ }
1298
+ }
1299
+ effectiveBackground = worstStop;
1300
+ minRatio = worstRatio;
1244
1301
  }
1245
- const wLevel = wcagLevel(textColor, typeof background === 'string' ? background : '#ffffff');
1302
+ const wLevel = wcagLevel(textColor, effectiveBackground);
1246
1303
  return { readable: minRatio >= minRequired, minContrastRatio: +minRatio.toFixed(2), wcagLevel: wLevel };
1247
1304
  }
1248
1305
  export function bestContrastPalette(background, palettes, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "color-value-tools",
3
- "version": "1.1.12",
3
+ "version": "1.2.1",
4
4
  "description": "Parse, convert and manipulate colors across hex, RGB, HSL, Lab, LCH, OKLCH and CMYK, with WCAG contrast checks and CSS variables. Zero dependencies.",
5
5
  "author": "macrulez <macrulezru@gmail.com> (https://macrulez.ru/en)",
6
6
  "license": "MIT",