@thejaredwilcurt/csslop 0.0.19 → 0.0.21

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.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @file Serializes the collected longhand values of a shorthand into the shortest valid shorthand value, one builder per shorthand family.
3
+ */
4
+
5
+ import { collapseShorthandParts } from '../value/shared.js';
6
+ import { splitTopLevelComponents } from '../value/syntax.js';
7
+
8
+ import { buildBackgroundShorthandValue } from './background.js';
9
+
10
+ /**
11
+ * @typedef {object} ShorthandComponents
12
+ * @property {Array} properties The longhand property names being merged, in shorthand order.
13
+ * @property {Map} valueMap A map of each longhand property name to its cleaned value.
14
+ * @property {Array} cleanValues The cleaned longhand values, in the same order as `properties`.
15
+ * @property {string} importantSuffix A trailing `!important` suffix, or an empty string.
16
+ */
17
+
18
+ /**
19
+ * Builds a `position-try` value, which only collapses while the order component
20
+ * is at its `normal` default.
21
+ *
22
+ * @param {ShorthandComponents} components The collected longhand values.
23
+ * @return {string|null} The shorthand value, or null when it cannot be built.
24
+ */
25
+ function buildPositionTryValue ({ valueMap, importantSuffix }) {
26
+ const order = valueMap.get('position-try-order');
27
+ const fallbacks = valueMap.get('position-try-fallbacks');
28
+ if (order === 'normal' && fallbacks) {
29
+ return fallbacks + importantSuffix;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Builds a `transition` value, omitting the trailing components that already
36
+ * hold their initial value.
37
+ *
38
+ * @param {ShorthandComponents} components The collected longhand values.
39
+ * @return {string|null} The shorthand value, or null when it cannot be built.
40
+ */
41
+ function buildTransitionValue ({ valueMap, importantSuffix }) {
42
+ const transitionProperty = valueMap.get('transition-property');
43
+ const duration = valueMap.get('transition-duration');
44
+ const timing = valueMap.get('transition-timing-function');
45
+ const delay = valueMap.get('transition-delay');
46
+ if (!transitionProperty || !duration) {
47
+ return null;
48
+ }
49
+ const result = [transitionProperty, duration];
50
+ if (timing && timing !== 'ease') {
51
+ result.push(timing);
52
+ }
53
+ if (delay && delay !== '0' && delay !== '0s') {
54
+ result.push(delay);
55
+ }
56
+ return result.join(' ') + importantSuffix;
57
+ }
58
+
59
+ /**
60
+ * Builds an `animation` value, omitting the components that already hold their
61
+ * initial value.
62
+ *
63
+ * @param {ShorthandComponents} components The collected longhand values.
64
+ * @return {string|null} The shorthand value, or null when it cannot be built.
65
+ */
66
+ function buildAnimationValue ({ valueMap, importantSuffix }) {
67
+ const animationName = valueMap.get('animation-name');
68
+ const duration = valueMap.get('animation-duration');
69
+ if (!animationName || !duration) {
70
+ return null;
71
+ }
72
+ const result = [animationName, duration];
73
+ const timing = valueMap.get('animation-timing-function');
74
+ const delay = valueMap.get('animation-delay');
75
+ const iteration = valueMap.get('animation-iteration-count');
76
+ const direction = valueMap.get('animation-direction');
77
+ const fillMode = valueMap.get('animation-fill-mode');
78
+ const playState = valueMap.get('animation-play-state');
79
+ if (timing && timing !== 'ease') {
80
+ result.push(timing);
81
+ }
82
+ if (delay && delay !== '0' && delay !== '0s') {
83
+ result.push(delay);
84
+ }
85
+ if (iteration && iteration !== '1') {
86
+ result.push(iteration);
87
+ }
88
+ if (direction && direction !== 'normal') {
89
+ result.push(direction);
90
+ }
91
+ if (fillMode && fillMode !== 'none') {
92
+ result.push(fillMode);
93
+ }
94
+ if (playState && playState !== 'running') {
95
+ result.push(playState);
96
+ }
97
+ return result.join(' ') + importantSuffix;
98
+ }
99
+
100
+ /**
101
+ * Builds a `background-position` value from its two axis longhands.
102
+ *
103
+ * @param {ShorthandComponents} components The collected longhand values.
104
+ * @return {string|null} The shorthand value, or null when it cannot be built.
105
+ */
106
+ function buildBackgroundPositionValue ({ valueMap, importantSuffix }) {
107
+ const positionX = valueMap.get('background-position-x');
108
+ const positionY = valueMap.get('background-position-y');
109
+ if (!positionX || !positionY) {
110
+ return null;
111
+ }
112
+ return positionX + ' ' + positionY + importantSuffix;
113
+ }
114
+
115
+ /**
116
+ * Builds a `background` value from its component longhands.
117
+ *
118
+ * @param {ShorthandComponents} components The collected longhand values.
119
+ * @return {string|null} The shorthand value, or null when it cannot be built.
120
+ */
121
+ function buildBackgroundValue ({ valueMap, importantSuffix }) {
122
+ return buildBackgroundShorthandValue(valueMap, importantSuffix);
123
+ }
124
+
125
+ /**
126
+ * Builds a `mask` value, attaching the size after the position separator.
127
+ *
128
+ * @param {ShorthandComponents} components The collected longhand values.
129
+ * @return {string|null} The shorthand value, or null when it cannot be built.
130
+ */
131
+ function buildMaskValue ({ valueMap, importantSuffix }) {
132
+ const image = valueMap.get('mask-image');
133
+ const repeat = valueMap.get('mask-repeat');
134
+ const size = valueMap.get('mask-size');
135
+ if (!image) {
136
+ return null;
137
+ }
138
+ let result = image;
139
+ if (repeat) {
140
+ result += ' ' + repeat;
141
+ }
142
+ if (size) {
143
+ result += '/' + size;
144
+ }
145
+ return result + importantSuffix;
146
+ }
147
+
148
+ /**
149
+ * Builds a `border-image` value, which requires a source to be meaningful.
150
+ *
151
+ * @param {ShorthandComponents} components The collected longhand values.
152
+ * @return {string|null} The shorthand value, or null when it cannot be built.
153
+ */
154
+ function buildBorderImageValue ({ valueMap, importantSuffix }) {
155
+ const source = valueMap.get('border-image-source');
156
+ const slice = valueMap.get('border-image-slice');
157
+ const repeat = valueMap.get('border-image-repeat');
158
+ if (!source) {
159
+ return null;
160
+ }
161
+ const result = [source];
162
+ if (slice) {
163
+ result.push(slice);
164
+ }
165
+ if (repeat) {
166
+ result.push(repeat);
167
+ }
168
+ return result.join(' ') + importantSuffix;
169
+ }
170
+
171
+ /**
172
+ * Builds a `text-decoration` value, omitting the default style and color.
173
+ *
174
+ * @param {ShorthandComponents} components The collected longhand values.
175
+ * @return {string|null} The shorthand value, or null when it cannot be built.
176
+ */
177
+ function buildTextDecorationValue ({ valueMap, importantSuffix }) {
178
+ const line = valueMap.get('text-decoration-line');
179
+ const style = valueMap.get('text-decoration-style');
180
+ const color = valueMap.get('text-decoration-color');
181
+ if (!line) {
182
+ return null;
183
+ }
184
+ const result = [line];
185
+ if (style && style !== 'solid') {
186
+ result.push(style);
187
+ }
188
+ if (color && color !== 'currentcolor') {
189
+ result.push(color);
190
+ }
191
+ return result.join(' ') + importantSuffix;
192
+ }
193
+
194
+ /**
195
+ * Builds a `columns` value, which keeps both components even when they match,
196
+ * because the width and count are not interchangeable.
197
+ *
198
+ * @param {ShorthandComponents} components The collected longhand values.
199
+ * @return {string|null} The shorthand value, or null when it cannot be built.
200
+ */
201
+ function buildColumnsValue ({ cleanValues, importantSuffix }) {
202
+ return cleanValues.join(' ') + importantSuffix;
203
+ }
204
+
205
+ /**
206
+ * Builds a `list-style` value, omitting default components and falling back to
207
+ * `inside` when every component holds its initial value.
208
+ *
209
+ * @param {ShorthandComponents} components The collected longhand values.
210
+ * @return {string|null} The shorthand value, or null when it cannot be built.
211
+ */
212
+ function buildListStyleValue ({ valueMap, importantSuffix }) {
213
+ const position = valueMap.get('list-style-position');
214
+ const image = valueMap.get('list-style-image');
215
+ const type = valueMap.get('list-style-type');
216
+ const result = [];
217
+ if (position && position !== 'outside') {
218
+ result.push(position);
219
+ }
220
+ if (image && image !== 'none') {
221
+ result.push(image);
222
+ }
223
+ if (type && type !== 'disc') {
224
+ result.push(type);
225
+ }
226
+ const joined = result.join(' ') || 'inside';
227
+ return joined + importantSuffix;
228
+ }
229
+
230
+ /**
231
+ * Builds a `font` value, which requires both a size and a family, and attaches
232
+ * any line height after the size separator.
233
+ *
234
+ * @param {ShorthandComponents} components The collected longhand values.
235
+ * @return {string|null} The shorthand value, or null when it cannot be built.
236
+ */
237
+ function buildFontValue ({ valueMap, importantSuffix }) {
238
+ const fontSize = valueMap.get('font-size');
239
+ const fontFamily = valueMap.get('font-family');
240
+ if (!fontSize || !fontFamily) {
241
+ return null;
242
+ }
243
+ const result = [];
244
+ const fontStyle = valueMap.get('font-style');
245
+ const fontWeight = valueMap.get('font-weight');
246
+ const lineHeight = valueMap.get('line-height');
247
+ if (fontStyle && fontStyle !== 'normal') {
248
+ result.push(fontStyle);
249
+ }
250
+ if (fontWeight && fontWeight !== '400' && fontWeight !== 'normal') {
251
+ result.push(fontWeight);
252
+ }
253
+ if (lineHeight) {
254
+ result.push(fontSize + '/' + lineHeight);
255
+ } else {
256
+ result.push(fontSize);
257
+ }
258
+ result.push(fontFamily);
259
+ return result.join(' ') + importantSuffix;
260
+ }
261
+
262
+ /**
263
+ * Builds a `flex` value from its three required components.
264
+ *
265
+ * @param {ShorthandComponents} components The collected longhand values.
266
+ * @return {string|null} The shorthand value, or null when it cannot be built.
267
+ */
268
+ function buildFlexValue ({ valueMap, importantSuffix }) {
269
+ const grow = valueMap.get('flex-grow');
270
+ const shrink = valueMap.get('flex-shrink');
271
+ const basis = valueMap.get('flex-basis');
272
+ if (!grow || !shrink || !basis) {
273
+ return null;
274
+ }
275
+ return [grow, shrink, basis].join(' ') + importantSuffix;
276
+ }
277
+
278
+ /**
279
+ * Determines whether a shorthand takes a single width, style, and color, as
280
+ * `border` and `outline` do.
281
+ *
282
+ * @param {Array} properties The longhand property names being merged.
283
+ * @return {boolean} Whether the longhands form a width/style/color trio.
284
+ */
285
+ function isWidthStyleColorTrio (properties) {
286
+ return (
287
+ properties.length === 3 &&
288
+ (properties.includes('border-width') || properties.includes('outline-width')) &&
289
+ properties.some((property) => {
290
+ // Check if one longhand ends with "-style" (e.g. border-style, outline-style)
291
+ return /-style$/.test(property);
292
+ }) &&
293
+ properties.some((property) => {
294
+ // Check if one longhand ends with "-color" (e.g. border-color, outline-color)
295
+ return /-color$/.test(property);
296
+ })
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Builds the value of a shorthand whose components are positional: two-value
302
+ * logical pairs, four-value box sides, and width/style/color trios.
303
+ *
304
+ * @param {ShorthandComponents} components The collected longhand values.
305
+ * @return {string|null} The shorthand value, or null when it cannot be built.
306
+ */
307
+ function buildPositionalShorthandValue ({ properties, cleanValues, importantSuffix }) {
308
+ if (properties.length === 2) {
309
+ // A logical pair collapses to one value when both sides match
310
+ if (cleanValues[0] === cleanValues[1]) {
311
+ return cleanValues[0] + importantSuffix;
312
+ }
313
+ return cleanValues.join(' ') + importantSuffix;
314
+ }
315
+
316
+ if (properties.length === 4) {
317
+ // Box sides collapse from top/right/bottom/left down to as few values as possible
318
+ return collapseShorthandParts([...cleanValues]).join(' ') + importantSuffix;
319
+ }
320
+
321
+ if (isWidthStyleColorTrio(properties)) {
322
+ // Every component of a border/outline shorthand accepts a single value, so a
323
+ // per-edge value such as `border-color:#0000 red` cannot be merged directly.
324
+ const hasOnlySingleComponentValues = cleanValues.every((value) => {
325
+ return splitTopLevelComponents(value).length === 1;
326
+ });
327
+ if (!hasOnlySingleComponentValues) {
328
+ return null;
329
+ }
330
+ return cleanValues.join(' ') + importantSuffix;
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Builders for shorthands whose components are identified by name rather than by
338
+ * position, keyed by shorthand property name.
339
+ *
340
+ * @type {{[key: string]: function(ShorthandComponents): (string|null)}}
341
+ */
342
+ const NAMED_SHORTHAND_BUILDERS = {
343
+ animation: buildAnimationValue,
344
+ background: buildBackgroundValue,
345
+ 'background-position': buildBackgroundPositionValue,
346
+ 'border-image': buildBorderImageValue,
347
+ columns: buildColumnsValue,
348
+ flex: buildFlexValue,
349
+ font: buildFontValue,
350
+ 'list-style': buildListStyleValue,
351
+ mask: buildMaskValue,
352
+ 'position-try': buildPositionTryValue,
353
+ 'text-decoration': buildTextDecorationValue,
354
+ transition: buildTransitionValue
355
+ };
356
+
357
+ /**
358
+ * Serializes the collected longhand values of a shorthand into its minified
359
+ * shorthand value, using the builder registered for that shorthand and falling
360
+ * back to positional assembly for box-model style shorthands.
361
+ *
362
+ * @param {string} shorthandName The target shorthand property name.
363
+ * @param {ShorthandComponents} components The collected longhand values.
364
+ * @return {string|null} The shorthand value, or null when it cannot be built.
365
+ */
366
+ function buildShorthandValue (shorthandName, components) {
367
+ const namedBuilder = NAMED_SHORTHAND_BUILDERS[shorthandName];
368
+ if (namedBuilder) {
369
+ return namedBuilder(components);
370
+ }
371
+ return buildPositionalShorthandValue(components);
372
+ }
373
+
374
+ export { buildShorthandValue };
@@ -70,7 +70,21 @@ function normalizeMedia (media) {
70
70
  return fullMatch;
71
71
  }
72
72
  );
73
- return media;
73
+ return compactLogicalOperators(media.trim());
74
+ }
75
+
76
+ /**
77
+ * Removes the whitespace between a closing parenthesis and a following `and`/`or`
78
+ * logical operator, which a closing parenthesis already separates unambiguously.
79
+ * The space after the operator is required, since it separates the operator from
80
+ * the next condition's opening parenthesis.
81
+ *
82
+ * @param {string} condition A normalized `@media` or `@supports` condition string.
83
+ * @return {string} The condition with tightened logical operator spacing.
84
+ */
85
+ function compactLogicalOperators (condition) {
86
+ // Compact logical operator spacing: ") and (" → ")and (", ") or (" → ")or ("
87
+ return condition.replace(/\)\s*(and|or)\s*\(/gi, ')$1 (');
74
88
  }
75
89
 
76
90
  /**
@@ -83,10 +97,7 @@ function normalizeSupports (supports) {
83
97
  // Collapse whitespace and strip spaces around punctuation
84
98
  supports = supports.replace(/\s+/g, ' ').replace(/\s*([:,])\s*/g, '$1').replace(/\s*([=<>])\s*/g, '$1').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').trim();
85
99
  supports = supports.replace(/\s+and\s+/g, ' and ').replace(/\s+or\s+/g, ' or ').replace(/\s+not\s+/g, ' not ');
86
- // Compact logical operator spacing: ") and (" → ")and ("
87
- supports = supports.replace(/\)\s*and\s*\(/g, ')and (');
88
- supports = supports.replace(/\)\s*or\s*\(/g, ')or (');
89
- return supports;
100
+ return compactLogicalOperators(supports);
90
101
  }
91
102
 
92
103
  /**
@@ -173,10 +173,77 @@ function mergeAdjacentWherePseudoClasses (selector) {
173
173
  return result;
174
174
  }
175
175
 
176
+ /**
177
+ * Matches a compound selector built exclusively from long-established simple
178
+ * selectors: an optional type or universal selector, followed by any number of
179
+ * id and class selectors. Anything else (pseudo-classes, pseudo-elements,
180
+ * attribute matchers, combinators, descendant sequences) is excluded, because
181
+ * those may be unrecognized by a browser and `:is()` forgiving parsing is what
182
+ * keeps the remaining selectors in the rule alive.
183
+ *
184
+ * @type {RegExp}
185
+ */
186
+ const BROWSER_SAFE_COMPOUND_SELECTOR = /^(?:\*|[a-zA-Z][a-zA-Z0-9_-]*)?(?:[#.][a-zA-Z_-][a-zA-Z0-9_-]*)*$/;
187
+
188
+ /**
189
+ * Matches every id or class selector within a compound selector, used to count
190
+ * each one's specificity contribution.
191
+ *
192
+ * @type {RegExp}
193
+ */
194
+ const ID_OR_CLASS_SELECTOR = /[#.][a-zA-Z_-][a-zA-Z0-9_-]*/g;
195
+
196
+ /**
197
+ * Computes the specificity of a compound selector known to consist only of
198
+ * type, universal, id, and class selectors, as an "ids,classes,types" key.
199
+ *
200
+ * @param {string} compoundSelector A browser-safe compound selector.
201
+ * @return {string} The specificity key for equality comparison.
202
+ */
203
+ function getSimpleCompoundSpecificityKey (compoundSelector) {
204
+ const idsAndClasses = compoundSelector.match(ID_OR_CLASS_SELECTOR) || [];
205
+ const identifierCount = idsAndClasses.filter((selector) => {
206
+ return selector.startsWith('#');
207
+ }).length;
208
+ const classCount = idsAndClasses.length - identifierCount;
209
+ // Whatever precedes the first id/class is the type or universal selector, if any
210
+ const typePortion = compoundSelector.split(/[#.]/)[0];
211
+ const typeCount = typePortion && typePortion !== '*' ? 1 : 0;
212
+ return identifierCount + ',' + classCount + ',' + typeCount;
213
+ }
214
+
215
+ /**
216
+ * Determines whether a `:is()` selector list can be decomposed into a plain
217
+ * comma-separated selector list. `:is()` applies the highest specificity of its
218
+ * arguments to every match, so decomposing is only equivalent when all
219
+ * arguments share one specificity. It also parses forgivingly, so every
220
+ * argument must additionally be a selector every browser understands.
221
+ *
222
+ * @param {Array} parts The selector strings inside the `:is()`.
223
+ * @return {boolean} True when the `:is()` wrapper can be dropped.
224
+ */
225
+ function canDecomposeIsSelector (parts) {
226
+ if (parts.length < 2) {
227
+ return false;
228
+ }
229
+ const allBrowserSafe = parts.every((part) => {
230
+ return part !== '' && BROWSER_SAFE_COMPOUND_SELECTOR.test(part);
231
+ });
232
+ if (!allBrowserSafe) {
233
+ return false;
234
+ }
235
+ const specificityKeys = parts.map((part) => {
236
+ return getSimpleCompoundSpecificityKey(part);
237
+ });
238
+ return specificityKeys.every((key) => {
239
+ return key === specificityKeys[0];
240
+ });
241
+ }
242
+
176
243
  /**
177
244
  * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
178
- * de-duplicating, sorting alphabetically, and conditionally expanding into individual
179
- * selectors when all parts are simple type/universal selectors with no modifications.
245
+ * de-duplicating, sorting alphabetically, and decomposing into individual selectors
246
+ * when the remaining parts are browser-safe and share one level of specificity.
180
247
  *
181
248
  * @param {string} selector A minified CSS selector string.
182
249
  * @return {Array} An array of one or more processed selector strings.
@@ -223,7 +290,6 @@ function processIsSelector (selector) {
223
290
  }
224
291
  }
225
292
  parts.push(currentPart);
226
- const originalCount = parts.length;
227
293
  // Replace :link + :visited with :any-link
228
294
  const hasLink = parts.includes(':link');
229
295
  const hasVisited = parts.includes(':visited');
@@ -243,11 +309,8 @@ function processIsSelector (selector) {
243
309
  if (parts.length === 1) {
244
310
  return parts;
245
311
  }
246
- // Expand if all parts are simple type/universal selectors and no dedup/replacement occurred
247
- const allSimple = parts.every((part) => {
248
- return /^[a-z*][a-z0-9-]*$/i.test(part);
249
- });
250
- if (allSimple && parts.length === originalCount) {
312
+ // Drop the :is() wrapper when the parts are equivalent as a plain selector list
313
+ if (canDecomposeIsSelector(parts)) {
251
314
  return parts;
252
315
  }
253
316
  return [':is(' + parts.join(',') + ')'];
@@ -121,12 +121,11 @@ function stringifyAtRule (rule, context) {
121
121
  /**
122
122
  * Converts a parsed CSS AST rule node into a minified CSS string, dispatching to specialized handlers for each rule type including selectors, `@media`, `@keyframes`, `@layer`, and other at-rules.
123
123
  *
124
- * @param {object} rule The AST rule node to stringify.
125
- * @param {object} context The minification context with registered custom property data.
126
- * @param {boolean} nested Whether this rule is nested inside another rule, affecting spacing.
127
- * @return {string} The minified CSS string for this rule, or an empty string if the rule is empty.
124
+ * @param {object} rule The AST rule node to stringify.
125
+ * @param {object} context The minification context with registered custom property data.
126
+ * @return {string} The minified CSS string for this rule, or an empty string if the rule is empty.
128
127
  */
129
- function stringifyRule (rule, context, nested = false) {
128
+ function stringifyRule (rule, context) {
130
129
  if (rule.type === 'rule') {
131
130
  let declarations = rule.declarations
132
131
  ?.filter((declaration) => {
@@ -308,7 +307,7 @@ function stringifyRule (rule, context, nested = false) {
308
307
  .join(';');
309
308
 
310
309
  let renderedNested = nestedRules.map((nestedRule) => {
311
- return stringifyRule(nestedRule, context, true);
310
+ return stringifyRule(nestedRule, context);
312
311
  }).join('');
313
312
 
314
313
  output.push(renderedDeclarations);
@@ -323,11 +322,11 @@ function stringifyRule (rule, context, nested = false) {
323
322
 
324
323
  if (rule.type === 'media') {
325
324
  const normalizedMedia = normalizeMedia(rule.media);
326
- // A custom-media reference is a parenthesized dashed-ident like (--modern);
327
- // no space is needed after @media when the query begins with such a token.
328
- const isCustomMediaReference = normalizedMedia.startsWith('(--');
325
+ // An opening parenthesis unambiguously starts the first media condition
326
+ // (including custom-media references like `(--modern)`), so the space after
327
+ // `@media` is only required when the query begins with an identifier.
329
328
  let separator;
330
- if ((nested && normalizedMedia.startsWith('(')) || isCustomMediaReference) {
329
+ if (normalizedMedia.startsWith('(')) {
331
330
  separator = '';
332
331
  } else {
333
332
  separator = ' ';
@@ -343,7 +342,7 @@ function stringifyRule (rule, context, nested = false) {
343
342
  return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
344
343
  }).join(';');
345
344
  const renderedRules = subRules.map((childRule) => {
346
- return stringifyRule(childRule, context, false);
345
+ return stringifyRule(childRule, context);
347
346
  }).join('');
348
347
  const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
349
348
  if (!children) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import {
6
+ convertOklchToHex,
6
7
  oklabToRgb,
7
8
  parseColor,
8
9
  rgbToOklab,
@@ -372,6 +373,11 @@ function evaluateColorMix (expr) {
372
373
  const C = lch1.C * t1 + lch2.C * t2;
373
374
  const H = interpolateHueShorter(lch1.H, lch2.H, t2);
374
375
  const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
376
+ // In-gamut results have an exact sRGB equivalent, which is always shorter than oklch()
377
+ const hex = convertOklchToHex(L, C, H, alpha >= 1 ? 1 : alpha);
378
+ if (hex) {
379
+ return hex;
380
+ }
375
381
  return formatOklch(L, C, H, alpha);
376
382
  }
377
383
 
@@ -508,6 +508,20 @@ function convertOklabToHex (L, a, b, alpha) {
508
508
  return rgbaToHex(r, g, bl, alpha !== undefined ? alpha : 1);
509
509
  }
510
510
 
511
+ /**
512
+ * Convert a standalone oklch() value to hex if it fits in the sRGB gamut; returns null if out-of-gamut.
513
+ *
514
+ * @param {number} L The OKLCH lightness component, 0 to 1.
515
+ * @param {number} C The OKLCH chroma component.
516
+ * @param {number} H The OKLCH hue angle in degrees.
517
+ * @param {number} alpha The alpha value from 0 to 1.
518
+ * @return {string|null} A hex color string, or null if the color is outside the sRGB gamut.
519
+ */
520
+ function convertOklchToHex (L, C, H, alpha) {
521
+ const lab = oklchToOklab(L, C, H);
522
+ return convertOklabToHex(lab.L, lab.a, lab.b, alpha);
523
+ }
524
+
511
525
  export {
512
526
  hslToRgbChannels,
513
527
  rgbaToHex,
@@ -517,6 +531,7 @@ export {
517
531
  parseHex,
518
532
  convertLabToHex,
519
533
  convertOklabToHex,
534
+ convertOklchToHex,
520
535
  shortestColor,
521
536
  srgbToOklab,
522
537
  oklabToSrgb,