From 09f19daa9e182e6972752eec6cbc0dd66c18b550 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 19 Oct 2023 15:30:40 -0400 Subject: [PATCH 01/25] Rough WIP for new color spaces. --- lib/src/protofier.ts | 110 +++++--- lib/src/value/color.ts | 571 +++++++++++++++++++++-------------------- package.json | 1 + 3 files changed, 366 insertions(+), 316 deletions(-) diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index e2d7f951..68fe79eb 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -8,7 +8,7 @@ import * as proto from './vendor/embedded_sass_pb'; import * as utils from './utils'; import {FunctionRegistry} from './function-registry'; import {SassArgumentList} from './value/argument-list'; -import {SassColor} from './value/color'; +import {SassColor, KnownColorSpace} from './value/color'; import {SassFunction} from './value/function'; import {SassList, ListSeparator} from './value/list'; import {SassMap} from './value/map'; @@ -66,21 +66,13 @@ export class Protofier { } else if (value instanceof SassNumber) { result.value = {case: 'number', value: this.protofyNumber(value)}; } else if (value instanceof SassColor) { - if (value.hasCalculatedHsl) { - const color = new proto.Value_HslColor(); - color.hue = value.hue; - color.saturation = value.saturation; - color.lightness = value.lightness; - color.alpha = value.alpha; - result.value = {case: 'hslColor', value: color}; - } else { - const color = new proto.Value_RgbColor(); - color.red = value.red; - color.green = value.green; - color.blue = value.blue; - color.alpha = value.alpha; - result.value = {case: 'rgbColor', value: color}; - } + const color = new proto.Value_Color(); + const channels = value.channels; + color.channel1 = channels.get(0) as number; + color.channel2 = channels.get(1) as number; + color.channel3 = channels.get(2) as number; + color.alpha = value.alpha; + result.value = {case: 'color', value: color}; } else if (value instanceof SassList) { const list = new proto.Value_List(); list.separator = this.protofySeparator(value.separator); @@ -242,24 +234,76 @@ export class Protofier { return this.deprotofyNumber(value.value.value); } - case 'rgbColor': { - const color = value.value.value; - return new SassColor({ - red: color.red, - green: color.green, - blue: color.blue, - alpha: color.alpha, - }); - } - - case 'hslColor': { + case 'color': { const color = value.value.value; - return new SassColor({ - hue: color.hue, - saturation: color.saturation, - lightness: color.lightness, - alpha: color.alpha, - }); + switch (color.space.toLowerCase()) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + return new SassColor({ + red: color.channel1, + green: color.channel2, + blue: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'hsl': + return new SassColor({ + hue: color.channel1, + saturation: color.channel2, + lightness: color.channel3, + alpha: color.alpha, + space: 'hsl', + }); + + case 'hwb': + return new SassColor({ + hue: color.channel1, + whiteness: color.channel2, + blackness: color.channel3, + alpha: color.alpha, + space: 'hwb', + }); + + case 'lab': + case 'oklab': + return new SassColor({ + lightness: color.channel1, + a: color.channel2, + b: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'lch': + case 'oklch': + return new SassColor({ + lightness: color.channel1, + chroma: color.channel2, + hue: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + return new SassColor({ + x: color.channel1, + y: color.channel2, + z: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + default: + throw utils.compilerError(`Unknown color space "${color.space}".`); + } } case 'list': { diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index b2aaad1f..695679bb 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -3,202 +3,299 @@ // https://opensource.org/licenses/MIT. import {Value} from './index'; +import {valueError} from '../utils'; +import {fuzzyAssertInRange, fuzzyEquals} from './utils'; +import {hash, List} from 'immutable'; import { - fuzzyAssertInRange, - fuzzyEquals, - fuzzyRound, - positiveMod, -} from './utils'; -import {hash} from 'immutable'; - -interface RgbColor { - red: number; - green: number; - blue: number; - alpha?: number; -} - -interface HslColor { - hue: number; - saturation: number; - lightness: number; - alpha?: number; + get, + getColor, + A98RGB, + ColorSpace, + HSL, + HWB, + LCH, + Lab, + OKLCH, + OKLab, + P3, + ProPhoto, + REC_2020, + XYZ_D50, + XYZ_D65, + sRGB, + sRGB_Linear, +} from 'colorjs.io/fn'; +import type {PlainColorObject} from 'colorjs.io/types/src/color'; + +// Register supported color spaces +ColorSpace.register(A98RGB); +ColorSpace.register(HSL); +ColorSpace.register(HWB); +ColorSpace.register(LCH); +ColorSpace.register(Lab); +ColorSpace.register(OKLCH); +ColorSpace.register(OKLab); +ColorSpace.register(P3); +ColorSpace.register(ProPhoto); +ColorSpace.register(REC_2020); +ColorSpace.register(XYZ_D50); +ColorSpace.register(XYZ_D65); +ColorSpace.register(sRGB); +ColorSpace.register(sRGB_Linear); + +/** The HSL color space name. */ +type ColorSpaceHsl = 'hsl'; + +/** The HSL color space channel names. */ +type ChannelNameHsl = 'hue' | 'saturation' | 'lightness' | 'alpha'; + +/** The HWB color space name. */ +type ColorSpaceHwb = 'hwb'; + +/** The HWB color space channel names. */ +type ChannelNameHwb = 'hue' | 'whiteness' | 'blackness' | 'alpha'; + +/** The Lab / Oklab color space names. */ +type ColorSpaceLab = 'lab' | 'oklab'; + +/** The Lab / Oklab color space channel names. */ +type ChannelNameLab = 'lightness' | 'a' | 'b' | 'alpha'; + +/** The LCH / Oklch color space names. */ +type ColorSpaceLch = 'lch' | 'oklch'; + +/** The LCH / Oklch color space channel names. */ +type ChannelNameLch = 'lightness' | 'chroma' | 'hue' | 'alpha'; + +/** Names of color spaces with RGB channels. */ +type ColorSpaceRgb = + | 'a98-rgb' + | 'display-p3' + | 'prophoto-rgb' + | 'rec2020' + | 'rgb' + | 'srgb' + | 'srgb-linear'; + +/** RGB channel names. */ +type ChannelNameRgb = 'red' | 'green' | 'blue' | 'alpha'; + +/** Names of color spaces with XYZ channels. */ +type ColorSpaceXyz = 'xyz' | 'xyz-d50' | 'xyz-d65'; + +/** XYZ channel names. */ +type ChannelNameXyz = 'x' | 'y' | 'z' | 'alpha'; + +/** All supported color space channel names. */ +type ChannelName = + | ChannelNameHsl + | ChannelNameHwb + | ChannelNameLab + | ChannelNameLch + | ChannelNameRgb + | ChannelNameXyz; + +/** All supported color space names. */ +export type KnownColorSpace = + | ColorSpaceHsl + | ColorSpaceHwb + | ColorSpaceLab + | ColorSpaceLch + | ColorSpaceRgb + | ColorSpaceXyz; + +type Channels = { + [key in ChannelName]?: number | null; +}; + +function getColorSpace(options: Channels) { + if (typeof options.red === 'number') { + return 'rgb'; + } + if (typeof options.saturation === 'number') { + return 'hsl'; + } + if (typeof options.whiteness === 'number') { + return 'hwb'; + } + throw valueError('No color space found'); } -interface HwbColor { - hue: number; - whiteness: number; - blackness: number; - alpha?: number; +function emitColor4ApiDeprecation(name: string) { + console.warn(`\`${name}\` is deprecated, use \`channel\` instead.`); } /** A SassScript color. */ export class SassColor extends Value { - private redInternal?: number; - private greenInternal?: number; - private blueInternal?: number; - private hueInternal?: number; - private saturationInternal?: number; - private lightnessInternal?: number; - private readonly alphaInternal: number; - - constructor(color: RgbColor); - constructor(color: HslColor); - constructor(color: HwbColor); - constructor(color: RgbColor | HslColor | HwbColor) { + private color: PlainColorObject; + + constructor(options: Channels & {space?: KnownColorSpace}) { super(); - if ('red' in color) { - this.redInternal = fuzzyAssertInRange( - Math.round(color.red), - 0, - 255, - 'red' - ); - this.greenInternal = fuzzyAssertInRange( - Math.round(color.green), - 0, - 255, - 'green' - ); - this.blueInternal = fuzzyAssertInRange( - Math.round(color.blue), - 0, - 255, - 'blue' - ); - } else if ('saturation' in color) { - this.hueInternal = positiveMod(color.hue, 360); - this.saturationInternal = fuzzyAssertInRange( - color.saturation, - 0, - 100, - 'saturation' - ); - this.lightnessInternal = fuzzyAssertInRange( - color.lightness, - 0, - 100, - 'lightness' - ); - } else { - // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb - const scaledHue = positiveMod(color.hue, 360) / 360; - let scaledWhiteness = - fuzzyAssertInRange(color.whiteness, 0, 100, 'whiteness') / 100; - let scaledBlackness = - fuzzyAssertInRange(color.blackness, 0, 100, 'blackness') / 100; - - const sum = scaledWhiteness + scaledBlackness; - if (sum > 1) { - scaledWhiteness /= sum; - scaledBlackness /= sum; - } - - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. Instead, we eagerly - // convert it to RGB and then convert back if necessary. - this.redInternal = hwbToRgb( - scaledHue + 1 / 3, - scaledWhiteness, - scaledBlackness - ); - this.greenInternal = hwbToRgb( - scaledHue, - scaledWhiteness, - scaledBlackness - ); - this.blueInternal = hwbToRgb( - scaledHue - 1 / 3, - scaledWhiteness, - scaledBlackness + if (options.alpha === null && !options.space) { + console.warn( + 'Passing `alpha: null` without setting `space` is deprecated.\n\nMore info: https://sass-lang.com/d/null-alpha' ); } - this.alphaInternal = - color.alpha === undefined + const space = options.space ?? getColorSpace(options); + // TODO(jgerigmeyer) What to do about `null` alpha? + const alpha = + options.alpha === undefined || options.alpha === null ? 1 - : fuzzyAssertInRange(color.alpha, 0, 1, 'alpha'); + : fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); + + switch (space) { + // TODO(jgerigmeyer) Is "rgb" a valid space for colorjs.io? + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + this.color = getColor({ + spaceId: space, + // TODO(jgerigmeyer) What to do about `null` or `undefined` channels? + coords: [options.red ?? 0, options.green ?? 0, options.blue ?? 0], + alpha, + }); + break; + + case 'hsl': + this.color = getColor({ + spaceId: space, + coords: [ + options.hue ?? 0, + options.saturation ?? 0, + options.lightness ?? 0, + ], + alpha, + }); + break; + + case 'hwb': + this.color = getColor({ + spaceId: space, + coords: [ + options.hue ?? 0, + options.whiteness ?? 0, + options.blackness ?? 0, + ], + alpha, + }); + break; + + case 'lab': + case 'oklab': + this.color = getColor({ + spaceId: space, + coords: [options.lightness ?? 0, options.a ?? 0, options.b ?? 0], + alpha, + }); + break; + + case 'lch': + case 'oklch': + this.color = getColor({ + spaceId: space, + coords: [ + options.lightness ?? 0, + options.chroma ?? 0, + options.hue ?? 0, + ], + alpha, + }); + break; + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + this.color = getColor({ + spaceId: space, + coords: [options.x ?? 0, options.y ?? 0, options.z ?? 0], + alpha, + }); + break; + } } /** `this`'s red channel. */ get red(): number { - if (this.redInternal === undefined) { - this.hslToRgb(); - } - return this.redInternal!; + emitColor4ApiDeprecation('red'); + return get(this.color, 'red'); } /** `this`'s blue channel. */ get blue(): number { - if (this.blueInternal === undefined) { - this.hslToRgb(); - } - return this.blueInternal!; + emitColor4ApiDeprecation('blue'); + return get(this.color, 'blue'); } /** `this`'s green channel. */ get green(): number { - if (this.greenInternal === undefined) { - this.hslToRgb(); - } - return this.greenInternal!; + emitColor4ApiDeprecation('green'); + return get(this.color, 'green'); } /** `this`'s hue value. */ get hue(): number { - if (this.hueInternal === undefined) { - this.rgbToHsl(); - } - return this.hueInternal!; + emitColor4ApiDeprecation('hue'); + return get(this.color, 'hue'); } /** `this`'s saturation value. */ get saturation(): number { - if (this.saturationInternal === undefined) { - this.rgbToHsl(); - } - return this.saturationInternal!; + emitColor4ApiDeprecation('saturation'); + return get(this.color, 'saturation'); } /** `this`'s hue value. */ get lightness(): number { - if (this.lightnessInternal === undefined) { - this.rgbToHsl(); - } - return this.lightnessInternal!; + emitColor4ApiDeprecation('lightness'); + return get(this.color, 'lightness'); } /** `this`'s whiteness value. */ get whiteness(): number { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return (Math.min(this.red, this.green, this.blue) / 255) * 100; + emitColor4ApiDeprecation('whiteness'); + return get(this.color, 'whiteness'); } /** `this`'s blackness value. */ get blackness(): number { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return 100 - (Math.max(this.red, this.green, this.blue) / 255) * 100; + emitColor4ApiDeprecation('blackness'); + return get(this.color, 'blackness'); } /** `this`'s alpha channel. */ get alpha(): number { - return this.alphaInternal; + return this.color.alpha; } - /** - * Whether `this` has already calculated the HSL components for the color. + /** `this`'s color space. */ + get space(): string { + return this.color.space.id; + } + + /** Whether `this` is in a legacy color space. */ + get isLegacy(): boolean { + return ['rgb', 'hsl', 'hwb'].includes(this.color.space.id); + } + + /** The values of this color's channels (excluding the alpha channel), or + * `null` for [missing] channels. * - * This is an internal property that's not an official part of Sass's JS API, - * and may be broken at any time. + * [missing]: https://www.w3.org/TR/css-color-4/#missing */ - get hasCalculatedHsl(): boolean { - return !!this.hueInternal; + get channelsOrNull(): List { + // TODO(jgerigmeyer) What to do about `null` channels? + return List(this.color.coords); + } + + /** The values of this color's channels (excluding the alpha channel). */ + get channels(): List { + return List(this.color.coords); } assertColor(): SassColor { @@ -208,54 +305,54 @@ export class SassColor extends Value { /** * Returns a copy of `this` with its channels changed to match `color`. */ - change(color: Partial): SassColor; - change(color: Partial): SassColor; - change(color: Partial): SassColor; - change( - color: Partial | Partial | Partial - ): SassColor { - if ('whiteness' in color || 'blackness' in color) { - return new SassColor({ - hue: color.hue ?? this.hue, - whiteness: color.whiteness ?? this.whiteness, - blackness: color.blackness ?? this.blackness, - alpha: color.alpha ?? this.alpha, - }); - } else if ( - 'hue' in color || - 'saturation' in color || - 'lightness' in color - ) { - // Tell TypeScript this isn't a Partial. - const hsl = color as Partial; - return new SassColor({ - hue: hsl.hue ?? this.hue, - saturation: hsl.saturation ?? this.saturation, - lightness: hsl.lightness ?? this.lightness, - alpha: hsl.alpha ?? this.alpha, - }); - } else if ( - 'red' in color || - 'green' in color || - 'blue' in color || - this.redInternal - ) { - const rgb = color as Partial; - return new SassColor({ - red: rgb.red ?? this.red, - green: rgb.green ?? this.green, - blue: rgb.blue ?? this.blue, - alpha: rgb.alpha ?? this.alpha, - }); - } else { - return new SassColor({ - hue: this.hue, - saturation: this.saturation, - lightness: this.lightness, - alpha: color.alpha ?? this.alpha, - }); - } - } + // change(color: Partial): SassColor; + // change(color: Partial): SassColor; + // change(color: Partial): SassColor; + // change( + // color: Partial | Partial | Partial + // ): SassColor { + // if ('whiteness' in color || 'blackness' in color) { + // return new SassColor({ + // hue: color.hue ?? this.hue, + // whiteness: color.whiteness ?? this.whiteness, + // blackness: color.blackness ?? this.blackness, + // alpha: color.alpha ?? this.alpha, + // }); + // } else if ( + // 'hue' in color || + // 'saturation' in color || + // 'lightness' in color + // ) { + // // Tell TypeScript this isn't a Partial. + // const hsl = color as Partial; + // return new SassColor({ + // hue: hsl.hue ?? this.hue, + // saturation: hsl.saturation ?? this.saturation, + // lightness: hsl.lightness ?? this.lightness, + // alpha: hsl.alpha ?? this.alpha, + // }); + // } else if ( + // 'red' in color || + // 'green' in color || + // 'blue' in color || + // this.redInternal + // ) { + // const rgb = color as Partial; + // return new SassColor({ + // red: rgb.red ?? this.red, + // green: rgb.green ?? this.green, + // blue: rgb.blue ?? this.blue, + // alpha: rgb.alpha ?? this.alpha, + // }); + // } else { + // return new SassColor({ + // hue: this.hue, + // saturation: this.saturation, + // lightness: this.lightness, + // alpha: color.alpha ?? this.alpha, + // }); + // } + // } equals(other: Value): boolean { return ( @@ -278,96 +375,4 @@ export class SassColor extends Value { string += isOpaque ? ')' : `, ${this.alpha})`; return string; } - - // Computes `this`'s `hue`, `saturation`, and `lightness` values based on - // `red`, `green`, and `blue`. - // - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV - private rgbToHsl(): void { - const scaledRed = this.red / 255; - const scaledGreen = this.green / 255; - const scaledBlue = this.blue / 255; - - const max = Math.max(scaledRed, scaledGreen, scaledBlue); - const min = Math.min(scaledRed, scaledGreen, scaledBlue); - const delta = max - min; - - if (max === min) { - this.hueInternal = 0; - } else if (max === scaledRed) { - this.hueInternal = positiveMod( - (60 * (scaledGreen - scaledBlue)) / delta, - 360 - ); - } else if (max === scaledGreen) { - this.hueInternal = positiveMod( - 120 + (60 * (scaledBlue - scaledRed)) / delta, - 360 - ); - } else if (max === scaledBlue) { - this.hueInternal = positiveMod( - 240 + (60 * (scaledRed - scaledGreen)) / delta, - 360 - ); - } - - this.lightnessInternal = 50 * (max + min); - - if (max === min) { - this.saturationInternal = 0; - } else if (this.lightnessInternal < 50) { - this.saturationInternal = (100 * delta) / (max + min); - } else { - this.saturationInternal = (100 * delta) / (2 - max - min); - } - } - - // Computes `this`'s red`, `green`, and `blue` channels based on `hue`, - // `saturation`, and `value`. - // - // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. - private hslToRgb(): void { - const scaledHue = this.hue / 360; - const scaledSaturation = this.saturation / 100; - const scaledLightness = this.lightness / 100; - - const m2 = - scaledLightness <= 0.5 - ? scaledLightness * (scaledSaturation + 1) - : scaledLightness + - scaledSaturation - - scaledLightness * scaledSaturation; - const m1 = scaledLightness * 2 - m2; - - this.redInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue + 1 / 3) * 255); - this.greenInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue) * 255); - this.blueInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue - 1 / 3) * 255); - } -} - -// A helper for converting HWB colors to RGB. -function hwbToRgb( - hue: number, - scaledWhiteness: number, - scaledBlackness: number -) { - const factor = 1 - scaledWhiteness - scaledBlackness; - const channel = hueToRgb(0, 1, hue) * factor + scaledWhiteness; - return fuzzyRound(channel * 255); -} - -// An algorithm from the CSS3 spec: http://www.w3.org/TR/css3-color/#hsl-color. -function hueToRgb(m1: number, m2: number, hue: number): number { - if (hue < 0) hue += 1; - if (hue > 1) hue -= 1; - - if (hue < 1 / 6) { - return m1 + (m2 - m1) * hue * 6; - } else if (hue < 1 / 2) { - return m2; - } else if (hue < 2 / 3) { - return m1 + (m2 - m1) * (2 / 3 - hue) * 6; - } else { - return m1; - } } diff --git a/package.json b/package.json index 41d9eb11..bc3daecc 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dependencies": { "@bufbuild/protobuf": "^1.0.0", "buffer-builder": "^0.2.0", + "colorjs.io": "^0.4.5", "immutable": "^4.0.0", "rxjs": "^7.4.0", "supports-color": "^8.1.1", From 5aff16b2cfc52934e1fe57f98e98d8fa90fb891c Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 19 Oct 2023 16:11:08 -0400 Subject: [PATCH 02/25] Update protocol version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bc3daecc..4c2703ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sass-embedded", "version": "1.69.4", - "protocol-version": "2.3.0", + "protocol-version": "3.0.0-dev", "compiler-version": "1.69.4", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node", From 66201891a88aca3d11d74e2d0f81653882dda83a Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 20 Oct 2023 14:02:26 -0500 Subject: [PATCH 03/25] Clean up missing components and rgb --- lib/src/value/color.ts | 138 ++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 31 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 695679bb..16b33a81 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -7,10 +7,14 @@ import {valueError} from '../utils'; import {fuzzyAssertInRange, fuzzyEquals} from './utils'; import {hash, List} from 'immutable'; import { + // Functions get, getColor, - A98RGB, + to, + // Color space registry ColorSpace, + // Color spaces + A98RGB, HSL, HWB, LCH, @@ -128,6 +132,7 @@ function emitColor4ApiDeprecation(name: string) { /** A SassScript color. */ export class SassColor extends Value { private color: PlainColorObject; + private format: 'rgb' | null = null; constructor(options: Channels & {space?: KnownColorSpace}) { super(); @@ -138,17 +143,43 @@ export class SassColor extends Value { ); } - const space = options.space ?? getColorSpace(options); - // TODO(jgerigmeyer) What to do about `null` alpha? + let space = options.space ?? getColorSpace(options); + if (space === 'rgb') { + space = 'srgb'; + this.format = 'rgb'; + } const alpha = - options.alpha === undefined || options.alpha === null + options.alpha === null + ? NaN + : options.alpha === undefined ? 1 : fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); switch (space) { - // TODO(jgerigmeyer) Is "rgb" a valid space for colorjs.io? - case 'rgb': case 'srgb': + if (this.format === 'rgb') { + // Convert from 0-255 to 0-1 + this.color = getColor({ + spaceId: space, + coords: [ + (options.red ?? NaN) / 255, + (options.green ?? NaN) / 255, + (options.blue ?? NaN) / 255, + ], + alpha, + }); + } else { + this.color = getColor({ + spaceId: space, + coords: [ + options.red ?? NaN, + options.green ?? NaN, + options.blue ?? NaN, + ], + alpha, + }); + } + break; case 'srgb-linear': case 'display-p3': case 'a98-rgb': @@ -156,8 +187,11 @@ export class SassColor extends Value { case 'rec2020': this.color = getColor({ spaceId: space, - // TODO(jgerigmeyer) What to do about `null` or `undefined` channels? - coords: [options.red ?? 0, options.green ?? 0, options.blue ?? 0], + coords: [ + options.red ?? NaN, + options.green ?? NaN, + options.blue ?? NaN, + ], alpha, }); break; @@ -166,9 +200,9 @@ export class SassColor extends Value { this.color = getColor({ spaceId: space, coords: [ - options.hue ?? 0, - options.saturation ?? 0, - options.lightness ?? 0, + options.hue ?? NaN, + options.saturation ?? NaN, + options.lightness ?? NaN, ], alpha, }); @@ -178,9 +212,9 @@ export class SassColor extends Value { this.color = getColor({ spaceId: space, coords: [ - options.hue ?? 0, - options.whiteness ?? 0, - options.blackness ?? 0, + options.hue ?? NaN, + options.whiteness ?? NaN, + options.blackness ?? NaN, ], alpha, }); @@ -190,7 +224,11 @@ export class SassColor extends Value { case 'oklab': this.color = getColor({ spaceId: space, - coords: [options.lightness ?? 0, options.a ?? 0, options.b ?? 0], + coords: [ + options.lightness ?? NaN, + options.a ?? NaN, + options.b ?? NaN, + ], alpha, }); break; @@ -200,9 +238,9 @@ export class SassColor extends Value { this.color = getColor({ spaceId: space, coords: [ - options.lightness ?? 0, - options.chroma ?? 0, - options.hue ?? 0, + options.lightness ?? NaN, + options.chroma ?? NaN, + options.hue ?? NaN, ], alpha, }); @@ -213,7 +251,7 @@ export class SassColor extends Value { case 'xyz-d50': this.color = getColor({ spaceId: space, - coords: [options.x ?? 0, options.y ?? 0, options.z ?? 0], + coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], alpha, }); break; @@ -223,49 +261,81 @@ export class SassColor extends Value { /** `this`'s red channel. */ get red(): number { emitColor4ApiDeprecation('red'); - return get(this.color, 'red'); + try { + return get(this.color, 'red'); + } catch (error) { + return get(to(this.color, 'srgb'), 'red'); + } } /** `this`'s blue channel. */ get blue(): number { emitColor4ApiDeprecation('blue'); - return get(this.color, 'blue'); + try { + return get(this.color, 'blue'); + } catch (error) { + return get(to(this.color, 'srgb'), 'blue'); + } } /** `this`'s green channel. */ get green(): number { emitColor4ApiDeprecation('green'); - return get(this.color, 'green'); + try { + return get(this.color, 'green'); + } catch (error) { + return get(to(this.color, 'srgb'), 'green'); + } } /** `this`'s hue value. */ get hue(): number { emitColor4ApiDeprecation('hue'); - return get(this.color, 'hue'); + try { + return get(this.color, 'hue'); + } catch (error) { + return get(to(this.color, 'hsl'), 'hue'); + } } /** `this`'s saturation value. */ get saturation(): number { emitColor4ApiDeprecation('saturation'); - return get(this.color, 'saturation'); + try { + return get(this.color, 'saturation'); + } catch (error) { + return get(to(this.color, 'hsl'), 'saturation'); + } } /** `this`'s hue value. */ get lightness(): number { emitColor4ApiDeprecation('lightness'); - return get(this.color, 'lightness'); + try { + return get(this.color, 'lightness'); + } catch (error) { + return get(to(this.color, 'hsl'), 'lightness'); + } } /** `this`'s whiteness value. */ get whiteness(): number { emitColor4ApiDeprecation('whiteness'); - return get(this.color, 'whiteness'); + try { + return get(this.color, 'whiteness'); + } catch (error) { + return get(to(this.color, 'hwb'), 'whiteness'); + } } /** `this`'s blackness value. */ get blackness(): number { emitColor4ApiDeprecation('blackness'); - return get(this.color, 'blackness'); + try { + return get(this.color, 'blackness'); + } catch (error) { + return get(to(this.color, 'hwb'), 'blackness'); + } } /** `this`'s alpha channel. */ @@ -275,12 +345,19 @@ export class SassColor extends Value { /** `this`'s color space. */ get space(): string { - return this.color.space.id; + const _space = this.color.space.id; + if (_space === 'srgb' && this.format === 'rgb') { + return 'rgb'; + } + return _space; } /** Whether `this` is in a legacy color space. */ get isLegacy(): boolean { - return ['rgb', 'hsl', 'hwb'].includes(this.color.space.id); + return ( + (this.space === 'srgb' && this.format === 'rgb') || + ['hsl', 'hwb'].includes(this.space) + ); } /** The values of this color's channels (excluding the alpha channel), or @@ -289,8 +366,7 @@ export class SassColor extends Value { * [missing]: https://www.w3.org/TR/css-color-4/#missing */ get channelsOrNull(): List { - // TODO(jgerigmeyer) What to do about `null` channels? - return List(this.color.coords); + return List(this.color.coords.map(c => (Number.isNaN(c) ? null : c))); } /** The values of this color's channels (excluding the alpha channel). */ From 1229be648b08079d44cd8a2e72dea7d1648a20cd Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 20 Oct 2023 14:09:46 -0500 Subject: [PATCH 04/25] typo --- lib/src/value/color.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 16b33a81..fc4db468 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -354,10 +354,7 @@ export class SassColor extends Value { /** Whether `this` is in a legacy color space. */ get isLegacy(): boolean { - return ( - (this.space === 'srgb' && this.format === 'rgb') || - ['hsl', 'hwb'].includes(this.space) - ); + return ['rgb', 'hsl', 'hwb'].includes(this.space); } /** The values of this color's channels (excluding the alpha channel), or From a27e7b6b50d4082f47de0a4a3bb2a520026ddefc Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 2 Nov 2023 15:53:33 -0400 Subject: [PATCH 05/25] Address initial review --- lib/src/value/color.ts | 149 +++++++++++++---------------------------- 1 file changed, 45 insertions(+), 104 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index fc4db468..bceeb357 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -6,46 +6,8 @@ import {Value} from './index'; import {valueError} from '../utils'; import {fuzzyAssertInRange, fuzzyEquals} from './utils'; import {hash, List} from 'immutable'; -import { - // Functions - get, - getColor, - to, - // Color space registry - ColorSpace, - // Color spaces - A98RGB, - HSL, - HWB, - LCH, - Lab, - OKLCH, - OKLab, - P3, - ProPhoto, - REC_2020, - XYZ_D50, - XYZ_D65, - sRGB, - sRGB_Linear, -} from 'colorjs.io/fn'; -import type {PlainColorObject} from 'colorjs.io/types/src/color'; - -// Register supported color spaces -ColorSpace.register(A98RGB); -ColorSpace.register(HSL); -ColorSpace.register(HWB); -ColorSpace.register(LCH); -ColorSpace.register(Lab); -ColorSpace.register(OKLCH); -ColorSpace.register(OKLab); -ColorSpace.register(P3); -ColorSpace.register(ProPhoto); -ColorSpace.register(REC_2020); -ColorSpace.register(XYZ_D50); -ColorSpace.register(XYZ_D65); -ColorSpace.register(sRGB); -ColorSpace.register(sRGB_Linear); +import Color from 'colorjs.io'; +import type ColorType from 'colorjs.io'; /** The HSL color space name. */ type ColorSpaceHsl = 'hsl'; @@ -129,37 +91,48 @@ function emitColor4ApiDeprecation(name: string) { console.warn(`\`${name}\` is deprecated, use \`channel\` instead.`); } +function NaNtoNull(val: number) { + return Number.isNaN(val) ? null : val; +} + +function NaNtoZero(val: number) { + return Number.isNaN(val) ? 0 : val; +} + /** A SassScript color. */ export class SassColor extends Value { - private color: PlainColorObject; - private format: 'rgb' | null = null; + private color: ColorType; + private isRgb = false; constructor(options: Channels & {space?: KnownColorSpace}) { super(); if (options.alpha === null && !options.space) { console.warn( - 'Passing `alpha: null` without setting `space` is deprecated.\n\nMore info: https://sass-lang.com/d/null-alpha' + 'Passing `alpha: null` without setting `space` is deprecated.\n\n' + + 'More info: https://sass-lang.com/d/null-alpha' ); } let space = options.space ?? getColorSpace(options); if (space === 'rgb') { space = 'srgb'; - this.format = 'rgb'; + this.isRgb = true; + } + let alpha; + if (options.alpha === null) { + alpha = NaN; + } else if (options.alpha === undefined) { + alpha = 1; + } else { + alpha = fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); } - const alpha = - options.alpha === null - ? NaN - : options.alpha === undefined - ? 1 - : fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); switch (space) { case 'srgb': - if (this.format === 'rgb') { + if (this.isRgb) { // Convert from 0-255 to 0-1 - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ (options.red ?? NaN) / 255, @@ -169,7 +142,7 @@ export class SassColor extends Value { alpha, }); } else { - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.red ?? NaN, @@ -185,7 +158,7 @@ export class SassColor extends Value { case 'a98-rgb': case 'prophoto-rgb': case 'rec2020': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.red ?? NaN, @@ -197,7 +170,7 @@ export class SassColor extends Value { break; case 'hsl': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.hue ?? NaN, @@ -209,7 +182,7 @@ export class SassColor extends Value { break; case 'hwb': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.hue ?? NaN, @@ -222,7 +195,7 @@ export class SassColor extends Value { case 'lab': case 'oklab': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.lightness ?? NaN, @@ -235,7 +208,7 @@ export class SassColor extends Value { case 'lch': case 'oklch': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [ options.lightness ?? NaN, @@ -249,7 +222,7 @@ export class SassColor extends Value { case 'xyz': case 'xyz-d65': case 'xyz-d50': - this.color = getColor({ + this.color = new Color({ spaceId: space, coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], alpha, @@ -261,92 +234,60 @@ export class SassColor extends Value { /** `this`'s red channel. */ get red(): number { emitColor4ApiDeprecation('red'); - try { - return get(this.color, 'red'); - } catch (error) { - return get(to(this.color, 'srgb'), 'red'); - } + return NaNtoZero(this.color.srgb.red * 255); } /** `this`'s blue channel. */ get blue(): number { emitColor4ApiDeprecation('blue'); - try { - return get(this.color, 'blue'); - } catch (error) { - return get(to(this.color, 'srgb'), 'blue'); - } + return NaNtoZero(this.color.srgb.blue * 255); } /** `this`'s green channel. */ get green(): number { emitColor4ApiDeprecation('green'); - try { - return get(this.color, 'green'); - } catch (error) { - return get(to(this.color, 'srgb'), 'green'); - } + return NaNtoZero(this.color.srgb.green * 255); } /** `this`'s hue value. */ get hue(): number { emitColor4ApiDeprecation('hue'); - try { - return get(this.color, 'hue'); - } catch (error) { - return get(to(this.color, 'hsl'), 'hue'); - } + return NaNtoZero(this.color.hsl.hue); } /** `this`'s saturation value. */ get saturation(): number { emitColor4ApiDeprecation('saturation'); - try { - return get(this.color, 'saturation'); - } catch (error) { - return get(to(this.color, 'hsl'), 'saturation'); - } + return NaNtoZero(this.color.hsl.saturation); } - /** `this`'s hue value. */ + /** `this`'s lightness value. */ get lightness(): number { emitColor4ApiDeprecation('lightness'); - try { - return get(this.color, 'lightness'); - } catch (error) { - return get(to(this.color, 'hsl'), 'lightness'); - } + return NaNtoZero(this.color.hsl.lightness); } /** `this`'s whiteness value. */ get whiteness(): number { emitColor4ApiDeprecation('whiteness'); - try { - return get(this.color, 'whiteness'); - } catch (error) { - return get(to(this.color, 'hwb'), 'whiteness'); - } + return NaNtoZero(this.color.hwb.whiteness); } /** `this`'s blackness value. */ get blackness(): number { emitColor4ApiDeprecation('blackness'); - try { - return get(this.color, 'blackness'); - } catch (error) { - return get(to(this.color, 'hwb'), 'blackness'); - } + return NaNtoZero(this.color.hwb.blackness); } /** `this`'s alpha channel. */ get alpha(): number { - return this.color.alpha; + return NaNtoZero(this.color.alpha); } /** `this`'s color space. */ get space(): string { const _space = this.color.space.id; - if (_space === 'srgb' && this.format === 'rgb') { + if (_space === 'srgb' && this.isRgb) { return 'rgb'; } return _space; @@ -363,12 +304,12 @@ export class SassColor extends Value { * [missing]: https://www.w3.org/TR/css-color-4/#missing */ get channelsOrNull(): List { - return List(this.color.coords.map(c => (Number.isNaN(c) ? null : c))); + return List(this.color.coords.map(NaNtoNull)); } /** The values of this color's channels (excluding the alpha channel). */ get channels(): List { - return List(this.color.coords); + return List(this.color.coords.map(NaNtoZero)); } assertColor(): SassColor { From 04010b6960e5c45fc3f5859655a79f91da3efc4c Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 2 Nov 2023 17:53:51 -0400 Subject: [PATCH 06/25] Adjust getters and constructor for clamped vals and NaN conversion --- lib/src/value/color.ts | 107 ++++++++++++++++++++++++++--------------- lib/src/value/utils.ts | 2 +- 2 files changed, 69 insertions(+), 40 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index bceeb357..f950fcd7 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -99,6 +99,14 @@ function NaNtoZero(val: number) { return Number.isNaN(val) ? 0 : val; } +function assertClamped(val: number, min: number, max: number, name: string) { + return Number.isNaN(val) ? val : fuzzyAssertInRange(val, min, max, name); +} + +function coordToRgb(val: number) { + return val * 255; +} + /** A SassScript color. */ export class SassColor extends Value { private color: ColorType; @@ -129,30 +137,31 @@ export class SassColor extends Value { } switch (space) { - case 'srgb': + case 'srgb': { + let red = options.red ?? NaN; + let green = options.green ?? NaN; + let blue = options.blue ?? NaN; if (this.isRgb) { - // Convert from 0-255 to 0-1 + if (!options.space) { + red = assertClamped(red, 0, 255, 'red'); + green = assertClamped(green, 0, 255, 'green'); + blue = assertClamped(blue, 0, 255, 'blue'); + } this.color = new Color({ spaceId: space, - coords: [ - (options.red ?? NaN) / 255, - (options.green ?? NaN) / 255, - (options.blue ?? NaN) / 255, - ], + // convert from 0-255 to 0-1 + coords: [red / 255, green / 255, blue / 255], alpha, }); } else { this.color = new Color({ spaceId: space, - coords: [ - options.red ?? NaN, - options.green ?? NaN, - options.blue ?? NaN, - ], + coords: [red, green, blue], alpha, }); } break; + } case 'srgb-linear': case 'display-p3': case 'a98-rgb': @@ -169,55 +178,67 @@ export class SassColor extends Value { }); break; - case 'hsl': + case 'hsl': { + const hue = options.hue ?? NaN; + let saturation = options.saturation ?? NaN; + let lightness = options.lightness ?? NaN; + if (!options.space) { + saturation = assertClamped(saturation, 0, 100, 'saturation'); + } + lightness = assertClamped(lightness, 0, 100, 'lightness'); this.color = new Color({ spaceId: space, - coords: [ - options.hue ?? NaN, - options.saturation ?? NaN, - options.lightness ?? NaN, - ], + coords: [hue, saturation, lightness], alpha, }); break; - - case 'hwb': + } + + case 'hwb': { + const hue = options.hue ?? NaN; + let whiteness = options.whiteness ?? NaN; + let blackness = options.blackness ?? NaN; + if (!options.space) { + whiteness = assertClamped(whiteness, 0, 100, 'whiteness'); + blackness = assertClamped(blackness, 0, 100, 'blackness'); + } this.color = new Color({ spaceId: space, - coords: [ - options.hue ?? NaN, - options.whiteness ?? NaN, - options.blackness ?? NaN, - ], + coords: [hue, whiteness, blackness], alpha, }); break; + } case 'lab': - case 'oklab': + case 'oklab': { + let lightness = options.lightness ?? NaN; + const a = options.a ?? NaN; + const b = options.b ?? NaN; + const maxLightness = space === 'lab' ? 100 : 1; + lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); this.color = new Color({ spaceId: space, - coords: [ - options.lightness ?? NaN, - options.a ?? NaN, - options.b ?? NaN, - ], + coords: [lightness, a, b], alpha, }); break; + } case 'lch': - case 'oklch': + case 'oklch': { + let lightness = options.lightness ?? NaN; + const chroma = options.chroma ?? NaN; + const hue = options.hue ?? NaN; + const maxLightness = space === 'lch' ? 100 : 1; + lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); this.color = new Color({ spaceId: space, - coords: [ - options.lightness ?? NaN, - options.chroma ?? NaN, - options.hue ?? NaN, - ], + coords: [lightness, chroma, hue], alpha, }); break; + } case 'xyz': case 'xyz-d65': @@ -304,12 +325,20 @@ export class SassColor extends Value { * [missing]: https://www.w3.org/TR/css-color-4/#missing */ get channelsOrNull(): List { - return List(this.color.coords.map(NaNtoNull)); + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; + } + return List(coords.map(NaNtoNull)); } /** The values of this color's channels (excluding the alpha channel). */ get channels(): List { - return List(this.color.coords.map(NaNtoZero)); + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; + } + return List(coords.map(NaNtoZero)); } assertColor(): SassColor { diff --git a/lib/src/value/utils.ts b/lib/src/value/utils.ts index 18584bfb..c1b856fe 100644 --- a/lib/src/value/utils.ts +++ b/lib/src/value/utils.ts @@ -115,7 +115,7 @@ export function fuzzyAssertInRange( throw valueError(`${num} must be between ${min} and ${max}`, name); } -/** Returns `dividend % modulus`, but always in the range `[0, modulus)`. */ +/** Returns `dividend % modulus`, but always in the range `[0, modulus]`. */ export function positiveMod(dividend: number, modulus: number) { const result = dividend % modulus; return result < 0 ? result + modulus : result; From b1005fbd1ae4dcd7c72b0337cab4ba6066262518 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 2 Nov 2023 18:32:00 -0400 Subject: [PATCH 07/25] Update equals, toString, hashCode --- lib/src/value/color.ts | 63 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index f950fcd7..da183482 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -4,8 +4,8 @@ import {Value} from './index'; import {valueError} from '../utils'; -import {fuzzyAssertInRange, fuzzyEquals} from './utils'; -import {hash, List} from 'immutable'; +import {fuzzyAssertInRange, fuzzyEquals, fuzzyHashCode} from './utils'; +import {List} from 'immutable'; import Color from 'colorjs.io'; import type ColorType from 'colorjs.io'; @@ -398,24 +398,63 @@ export class SassColor extends Value { // } equals(other: Value): boolean { + if (!(other instanceof SassColor)) return false; + let coords = this.color.coords; + let otherCoords = other.color.coords; + if (this.isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEquals(this.alpha, other.alpha)) return false; + if (!(this.space === 'rgb' && other.space === 'rgb')) { + coords = this.color.to('srgb').coords.map(coordToRgb) as [ + number, + number, + number, + ]; + otherCoords = other.color.to('srgb').coords.map(coordToRgb) as [ + number, + number, + number, + ]; + } + return ( + fuzzyEquals(coords[0], otherCoords[0]) && + fuzzyEquals(coords[1], otherCoords[1]) && + fuzzyEquals(coords[2], otherCoords[2]) + ); + } return ( - other instanceof SassColor && - fuzzyEquals(this.red, other.red) && - fuzzyEquals(this.green, other.green) && - fuzzyEquals(this.blue, other.blue) && + this.space === other.space && + fuzzyEquals(coords[0], otherCoords[0]) && + fuzzyEquals(coords[1], otherCoords[1]) && + fuzzyEquals(coords[2], otherCoords[2]) && fuzzyEquals(this.alpha, other.alpha) ); } hashCode(): number { - return hash(this.red ^ this.green ^ this.blue ^ this.alpha); + let coords = this.color.coords; + if (this.isLegacy) { + coords = this.color.to('srgb').coords.map(coordToRgb) as [ + number, + number, + number, + ]; + return ( + fuzzyHashCode(coords[0]) ^ + fuzzyHashCode(coords[1]) ^ + fuzzyHashCode(coords[2]) ^ + fuzzyHashCode(this.alpha) + ); + } + return ( + fuzzyHashCode(coords[0]) ^ + fuzzyHashCode(coords[1]) ^ + fuzzyHashCode(coords[2]) ^ + fuzzyHashCode(this.alpha) + ); } toString(): string { - const isOpaque = fuzzyEquals(this.alpha, 1); - let string = isOpaque ? 'rgb(' : 'rgba('; - string += `${this.red}, ${this.green}, ${this.blue}`; - string += isOpaque ? ')' : `, ${this.alpha})`; - return string; + return this.color.toString({inGamut: false}); } } From 0f14effd436c15761cba42e9bdfde254ab032d36 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 2 Nov 2023 23:08:42 -0400 Subject: [PATCH 08/25] Do not mutate SassColor; implement remaining except change and interpolate. --- lib/src/value/color.ts | 263 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 234 insertions(+), 29 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index da183482..c6a21ea5 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -107,10 +107,29 @@ function coordToRgb(val: number) { return val * 255; } +function normalizeRgb(space?: KnownColorSpace) { + return normalizeRgb(space); +} + /** A SassScript color. */ export class SassColor extends Value { private color: ColorType; private isRgb = false; + private channel0Id: string; + private channel1Id: string; + private channel2Id: string; + private clone() { + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; + } + return new SassColor({ + space: this.space, + [this.channel0Id]: coords[0], + [this.channel1Id]: coords[1], + [this.channel2Id]: coords[2], + }); + } constructor(options: Channels & {space?: KnownColorSpace}) { super(); @@ -138,6 +157,9 @@ export class SassColor extends Value { switch (space) { case 'srgb': { + this.channel0Id = 'red'; + this.channel1Id = 'green'; + this.channel2Id = 'blue'; let red = options.red ?? NaN; let green = options.green ?? NaN; let blue = options.blue ?? NaN; @@ -162,11 +184,15 @@ export class SassColor extends Value { } break; } + case 'srgb-linear': case 'display-p3': case 'a98-rgb': case 'prophoto-rgb': case 'rec2020': + this.channel0Id = 'red'; + this.channel1Id = 'green'; + this.channel2Id = 'blue'; this.color = new Color({ spaceId: space, coords: [ @@ -179,6 +205,9 @@ export class SassColor extends Value { break; case 'hsl': { + this.channel0Id = 'hue'; + this.channel1Id = 'saturation'; + this.channel2Id = 'lightness'; const hue = options.hue ?? NaN; let saturation = options.saturation ?? NaN; let lightness = options.lightness ?? NaN; @@ -195,6 +224,9 @@ export class SassColor extends Value { } case 'hwb': { + this.channel0Id = 'hue'; + this.channel1Id = 'whiteness'; + this.channel2Id = 'blackness'; const hue = options.hue ?? NaN; let whiteness = options.whiteness ?? NaN; let blackness = options.blackness ?? NaN; @@ -212,6 +244,9 @@ export class SassColor extends Value { case 'lab': case 'oklab': { + this.channel0Id = 'lightness'; + this.channel1Id = 'a'; + this.channel2Id = 'b'; let lightness = options.lightness ?? NaN; const a = options.a ?? NaN; const b = options.b ?? NaN; @@ -227,6 +262,9 @@ export class SassColor extends Value { case 'lch': case 'oklch': { + this.channel0Id = 'lightness'; + this.channel1Id = 'chroma'; + this.channel2Id = 'hue'; let lightness = options.lightness ?? NaN; const chroma = options.chroma ?? NaN; const hue = options.hue ?? NaN; @@ -243,6 +281,9 @@ export class SassColor extends Value { case 'xyz': case 'xyz-d65': case 'xyz-d50': + this.channel0Id = 'x'; + this.channel1Id = 'y'; + this.channel2Id = 'z'; this.color = new Color({ spaceId: space, coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], @@ -252,6 +293,47 @@ export class SassColor extends Value { } } + /** `this`'s alpha channel. */ + get alpha(): number { + return NaNtoZero(this.color.alpha); + } + + /** `this`'s color space. */ + get space(): KnownColorSpace { + const _space = this.color.spaceId as Exclude; + if (_space === 'srgb' && this.isRgb) { + return 'rgb'; + } + return _space; + } + + /** Whether `this` is in a legacy color space. */ + get isLegacy(): boolean { + return ['rgb', 'hsl', 'hwb'].includes(this.space); + } + + /** The values of this color's channels (excluding the alpha channel), or + * `null` for [missing] channels. + * + * [missing]: https://www.w3.org/TR/css-color-4/#missing + */ + get channelsOrNull(): List { + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; + } + return List(coords.map(NaNtoNull)); + } + + /** The values of this color's channels (excluding the alpha channel). */ + get channels(): List { + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; + } + return List(coords.map(NaNtoZero)); + } + /** `this`'s red channel. */ get red(): number { emitColor4ApiDeprecation('red'); @@ -300,49 +382,172 @@ export class SassColor extends Value { return NaNtoZero(this.color.hwb.blackness); } - /** `this`'s alpha channel. */ - get alpha(): number { - return NaNtoZero(this.color.alpha); + assertColor(): SassColor { + return this; } - /** `this`'s color space. */ - get space(): string { - const _space = this.color.space.id; - if (_space === 'srgb' && this.isRgb) { - return 'rgb'; + _toSpaceInternal(space: KnownColorSpace) { + if (space === 'rgb') { + this.isRgb = true; + this.color = this.color.to('srgb'); + } else { + this.isRgb = false; + this.color = this.color.to(space); } - return _space; } - /** Whether `this` is in a legacy color space. */ - get isLegacy(): boolean { - return ['rgb', 'hsl', 'hwb'].includes(this.space); + /** + * Returns this color converted to the specified `space`. + */ + toSpace(space: KnownColorSpace): SassColor { + if (space === this.space) return this; + const color = this.clone(); + color._toSpaceInternal(space); + return color; } - /** The values of this color's channels (excluding the alpha channel), or - * `null` for [missing] channels. + /** + * Returns a boolean indicating whether this color is in-gamut (as opposed to + * having one or more of its channels out of bounds) for the specified + * `space`, or its current color space if `space` is not specified. + */ + isInGamut(space?: KnownColorSpace): boolean { + return this.color.inGamut(normalizeRgb(space)); + } + + _toGamutInternal(space?: KnownColorSpace) { + this.color.toGamut({space: normalizeRgb(space)}); + } + + /** + * Returns this color, modified so it is in-gamut for the specified `space`—or + * the current color space if `space` is not specified—using the recommended + * [CSS Gamut Mapping Algorithm][css-mapping] to map out-of-gamut colors into + * the desired gamut with as little perceptual change as possible. * - * [missing]: https://www.w3.org/TR/css-color-4/#missing + * [css-mapping]: https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm */ - get channelsOrNull(): List { - let coords = this.color.coords; - if (this.space === 'rgb') { - coords = coords.map(coordToRgb) as [number, number, number]; - } - return List(coords.map(NaNtoNull)); + toGamut(space?: KnownColorSpace): SassColor { + if (this.isInGamut(space)) return this; + const color = this.clone(); + color._toGamutInternal(space); + return color; } - /** The values of this color's channels (excluding the alpha channel). */ - get channels(): List { - let coords = this.color.coords; - if (this.space === 'rgb') { - coords = coords.map(coordToRgb) as [number, number, number]; + /** + * Returns the value of a single specified `channel` of this color (optionally + * after converting this color to the specified `space`), with [missing + * channels] converted to `0`. + * + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + channel(channel: ChannelName): number; + channel(channel: ChannelNameHsl, options: {space: ColorSpaceHsl}): number; + channel(channel: ChannelNameHwb, options: {space: ColorSpaceHwb}): number; + channel(channel: ChannelNameLab, options: {space: ColorSpaceLab}): number; + channel(channel: ChannelNameLch, options: {space: ColorSpaceLch}): number; + channel(channel: ChannelNameRgb, options: {space: ColorSpaceRgb}): number; + channel(channel: ChannelNameXyz, options: {space: ColorSpaceXyz}): number; + channel(channel: ChannelName, options?: {space: KnownColorSpace}): number { + let val: number; + const space = options?.space ?? this.space; + if (options?.space) { + val = this.color[normalizeRgb(options.space)][channel]; + } else { + val = this.color.get(channel); } - return List(coords.map(NaNtoZero)); + if (space === 'rgb' && channel !== 'alpha') { + val = val * 255; + } + return NaNtoZero(val); } - assertColor(): SassColor { - return this; + /** + * Returns a boolean indicating whether a given channel value is a [missing + * channel]. + * + * [missing channel]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + isChannelMissing(channel: ChannelName): boolean { + return Number.isNaN(this.color.get(channel)); + } + + /** + * Returns a boolean indicating whether a given `channel` is [powerless] in + * this color. This is a special state that's defined for individual color + * spaces, which indicates that a channel's value won't affect how a color is + * displayed. + * + * [powerless]: https://www.w3.org/TR/css-color-4/#powerless + */ + isChannelPowerless(channel: ChannelName): boolean; + isChannelPowerless( + channel: ChannelNameHsl, + options?: {space: ColorSpaceHsl} + ): boolean; + isChannelPowerless( + channel: ChannelNameHwb, + options?: {space: ColorSpaceHwb} + ): boolean; + isChannelPowerless( + channel: ChannelNameLab, + options?: {space: ColorSpaceLab} + ): boolean; + isChannelPowerless( + channel: ChannelNameLch, + options?: {space: ColorSpaceLch} + ): boolean; + isChannelPowerless( + channel: ChannelNameRgb, + options?: {space: ColorSpaceRgb} + ): boolean; + isChannelPowerless( + channel: ChannelNameXyz, + options?: {space: ColorSpaceXyz} + ): boolean; + isChannelPowerless( + channel: ChannelName, + options?: {space: KnownColorSpace} + ): boolean { + if (channel === 'alpha') return false; + const color = options?.space ? this.toSpace(options.space) : this; + const channels = color.channels.toArray(); + switch (channel) { + case color.channel0Id: + if (color.space === 'hsl') { + return fuzzyEquals(channels[1], 0) || fuzzyEquals(channels[2], 0); + } + if (color.space === 'hwb') { + return fuzzyEquals(channels[1] + channels[2], 100); + } + return false; + case color.channel1Id: + switch (color.space) { + case 'hsl': + return fuzzyEquals(channels[2], 0); + case 'lab': + case 'oklab': + case 'lch': + case 'oklch': + return fuzzyEquals(channels[0], 0) || fuzzyEquals(channels[0], 100); + } + return false; + case color.channel2Id: + switch (color.space) { + case 'lab': + case 'oklab': + return fuzzyEquals(channels[0], 0) || fuzzyEquals(channels[0], 100); + case 'lch': + case 'oklch': + return ( + fuzzyEquals(channels[0], 0) || + fuzzyEquals(channels[0], 100) || + fuzzyEquals(channels[1], 0) + ); + } + return false; + } + return false; } /** From 7b7c1e6affdd7edc0fcbdae57dfd858e454efb89 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 2 Nov 2023 23:15:25 -0400 Subject: [PATCH 09/25] Stub missing fns to pass type checks --- lib/src/value/color.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index c6a21ea5..53ebce7d 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -70,6 +70,16 @@ export type KnownColorSpace = | ColorSpaceRgb | ColorSpaceXyz; +/** + * Methods by which two hues are adjusted when interpolating between polar + * colors. + */ +export type HueInterpolationMethod = + | 'decreasing' + | 'increasing' + | 'longer' + | 'shorter'; + type Channels = { [key in ChannelName]?: number | null; }; @@ -550,6 +560,26 @@ export class SassColor extends Value { return false; } + // TODO(jgerigmeyer): Temp fns to pass type checks + change( + options: { + [key in ChannelName]?: number | null; + } & { + space?: KnownColorSpace; + } + ) { + return this; + } + interpolate( + color2: SassColor, + options: { + weight?: number; + method?: HueInterpolationMethod; + } + ) { + return this; + } + /** * Returns a copy of `this` with its channels changed to match `color`. */ From 5badae1ab8dd3bee046ad5253fe9e9fdd2a3aca6 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 3 Nov 2023 16:17:07 -0400 Subject: [PATCH 10/25] Start fixing based on failing tests --- lib/src/value/color.ts | 170 +++++++++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 65 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 53ebce7d..9d50d9ed 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -4,7 +4,13 @@ import {Value} from './index'; import {valueError} from '../utils'; -import {fuzzyAssertInRange, fuzzyEquals, fuzzyHashCode} from './utils'; +import { + fuzzyAssertInRange, + fuzzyEquals, + fuzzyHashCode, + fuzzyRound, + positiveMod, +} from './utils'; import {List} from 'immutable'; import Color from 'colorjs.io'; import type ColorType from 'colorjs.io'; @@ -98,7 +104,18 @@ function getColorSpace(options: Channels) { } function emitColor4ApiDeprecation(name: string) { - console.warn(`\`${name}\` is deprecated, use \`channel\` instead.`); + console.warn( + `Deprecation [color-4-api]: \`${name}\` is deprecated; use \`channel\` instead.` + ); +} + +function emitNullAlphaDeprecation() { + console.warn( + 'Deprecation [null-alpha]: ' + + 'Passing `alpha: null` without setting `space` is deprecated.' + + '\n' + + 'More info: https://sass-lang.com/d/null-alpha' + ); } function NaNtoNull(val: number) { @@ -117,8 +134,36 @@ function coordToRgb(val: number) { return val * 255; } -function normalizeRgb(space?: KnownColorSpace) { - return normalizeRgb(space); +function normalizeHue(val: number) { + return positiveMod(val, 360); +} + +function encodeSpaceForColorJs(space?: KnownColorSpace) { + switch (space) { + case 'rgb': + return 'srgb'; + case 'a98-rgb': + return 'a98rgb'; + case 'display-p3': + return 'p3'; + case 'prophoto-rgb': + return 'prophoto'; + } + return space; +} + +function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { + switch (space) { + case 'srgb': + return isRgb ? 'rgb' : space; + case 'a98rgb': + return 'a98-rgb'; + case 'p3': + return 'display-p3'; + case 'prophoto': + return 'prophoto-rgb'; + } + return space as KnownColorSpace; } /** A SassScript color. */ @@ -151,13 +196,15 @@ export class SassColor extends Value { ); } - let space = options.space ?? getColorSpace(options); + const space = options.space ?? getColorSpace(options); if (space === 'rgb') { - space = 'srgb'; this.isRgb = true; } let alpha; if (options.alpha === null) { + if (!options.space) { + emitNullAlphaDeprecation(); + } alpha = NaN; } else if (options.alpha === undefined) { alpha = 1; @@ -166,28 +213,24 @@ export class SassColor extends Value { } switch (space) { + case 'rgb': case 'srgb': { this.channel0Id = 'red'; this.channel1Id = 'green'; this.channel2Id = 'blue'; - let red = options.red ?? NaN; - let green = options.green ?? NaN; - let blue = options.blue ?? NaN; + const red = options.red ?? NaN; + const green = options.green ?? NaN; + const blue = options.blue ?? NaN; if (this.isRgb) { - if (!options.space) { - red = assertClamped(red, 0, 255, 'red'); - green = assertClamped(green, 0, 255, 'green'); - blue = assertClamped(blue, 0, 255, 'blue'); - } this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), // convert from 0-255 to 0-1 coords: [red / 255, green / 255, blue / 255], alpha, }); } else { this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [red, green, blue], alpha, }); @@ -204,7 +247,7 @@ export class SassColor extends Value { this.channel1Id = 'green'; this.channel2Id = 'blue'; this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [ options.red ?? NaN, options.green ?? NaN, @@ -218,15 +261,12 @@ export class SassColor extends Value { this.channel0Id = 'hue'; this.channel1Id = 'saturation'; this.channel2Id = 'lightness'; - const hue = options.hue ?? NaN; - let saturation = options.saturation ?? NaN; + const hue = normalizeHue(options.hue ?? NaN); + const saturation = options.saturation ?? NaN; let lightness = options.lightness ?? NaN; - if (!options.space) { - saturation = assertClamped(saturation, 0, 100, 'saturation'); - } lightness = assertClamped(lightness, 0, 100, 'lightness'); this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [hue, saturation, lightness], alpha, }); @@ -237,15 +277,11 @@ export class SassColor extends Value { this.channel0Id = 'hue'; this.channel1Id = 'whiteness'; this.channel2Id = 'blackness'; - const hue = options.hue ?? NaN; - let whiteness = options.whiteness ?? NaN; - let blackness = options.blackness ?? NaN; - if (!options.space) { - whiteness = assertClamped(whiteness, 0, 100, 'whiteness'); - blackness = assertClamped(blackness, 0, 100, 'blackness'); - } + const hue = normalizeHue(options.hue ?? NaN); + const whiteness = options.whiteness ?? NaN; + const blackness = options.blackness ?? NaN; this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [hue, whiteness, blackness], alpha, }); @@ -263,7 +299,7 @@ export class SassColor extends Value { const maxLightness = space === 'lab' ? 100 : 1; lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [lightness, a, b], alpha, }); @@ -277,11 +313,11 @@ export class SassColor extends Value { this.channel2Id = 'hue'; let lightness = options.lightness ?? NaN; const chroma = options.chroma ?? NaN; - const hue = options.hue ?? NaN; + const hue = normalizeHue(options.hue ?? NaN); const maxLightness = space === 'lch' ? 100 : 1; lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [lightness, chroma, hue], alpha, }); @@ -295,7 +331,7 @@ export class SassColor extends Value { this.channel1Id = 'y'; this.channel2Id = 'z'; this.color = new Color({ - spaceId: space, + spaceId: encodeSpaceForColorJs(space), coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], alpha, }); @@ -310,11 +346,7 @@ export class SassColor extends Value { /** `this`'s color space. */ get space(): KnownColorSpace { - const _space = this.color.spaceId as Exclude; - if (_space === 'srgb' && this.isRgb) { - return 'rgb'; - } - return _space; + return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb); } /** Whether `this` is in a legacy color space. */ @@ -347,19 +379,31 @@ export class SassColor extends Value { /** `this`'s red channel. */ get red(): number { emitColor4ApiDeprecation('red'); - return NaNtoZero(this.color.srgb.red * 255); + let val = NaNtoZero(coordToRgb(this.color.srgb.red)); + if (this.isLegacy && this.space !== 'rgb') { + val = fuzzyRound(val); + } + return val; } /** `this`'s blue channel. */ get blue(): number { emitColor4ApiDeprecation('blue'); - return NaNtoZero(this.color.srgb.blue * 255); + let val = NaNtoZero(coordToRgb(this.color.srgb.blue)); + if (this.isLegacy && this.space !== 'rgb') { + val = fuzzyRound(val); + } + return val; } /** `this`'s green channel. */ get green(): number { emitColor4ApiDeprecation('green'); - return NaNtoZero(this.color.srgb.green * 255); + let val = NaNtoZero(coordToRgb(this.color.srgb.green)); + if (this.isLegacy && this.space !== 'rgb') { + val = fuzzyRound(val); + } + return val; } /** `this`'s hue value. */ @@ -397,13 +441,8 @@ export class SassColor extends Value { } _toSpaceInternal(space: KnownColorSpace) { - if (space === 'rgb') { - this.isRgb = true; - this.color = this.color.to('srgb'); - } else { - this.isRgb = false; - this.color = this.color.to(space); - } + this.isRgb = space === 'rgb'; + this.color = this.color.to(encodeSpaceForColorJs(space) as string); } /** @@ -422,11 +461,11 @@ export class SassColor extends Value { * `space`, or its current color space if `space` is not specified. */ isInGamut(space?: KnownColorSpace): boolean { - return this.color.inGamut(normalizeRgb(space)); + return this.color.inGamut(encodeSpaceForColorJs(space)); } _toGamutInternal(space?: KnownColorSpace) { - this.color.toGamut({space: normalizeRgb(space)}); + this.color.toGamut({space: encodeSpaceForColorJs(space)}); } /** @@ -462,12 +501,15 @@ export class SassColor extends Value { let val: number; const space = options?.space ?? this.space; if (options?.space) { - val = this.color[normalizeRgb(options.space)][channel]; + val = this.color.get({ + space: encodeSpaceForColorJs(options.space) as string, + coordId: channel, + }); } else { val = this.color.get(channel); } if (space === 'rgb' && channel !== 'alpha') { - val = val * 255; + val = coordToRgb(val); } return NaNtoZero(val); } @@ -640,16 +682,14 @@ export class SassColor extends Value { if (!other.isLegacy) return false; if (!fuzzyEquals(this.alpha, other.alpha)) return false; if (!(this.space === 'rgb' && other.space === 'rgb')) { - coords = this.color.to('srgb').coords.map(coordToRgb) as [ - number, - number, - number, - ]; - otherCoords = other.color.to('srgb').coords.map(coordToRgb) as [ - number, - number, - number, - ]; + coords = this.color + .to('srgb') + .coords.map(coordToRgb) + .map(fuzzyRound) as [number, number, number]; + otherCoords = other.color + .to('srgb') + .coords.map(coordToRgb) + .map(fuzzyRound) as [number, number, number]; } return ( fuzzyEquals(coords[0], otherCoords[0]) && @@ -669,7 +709,7 @@ export class SassColor extends Value { hashCode(): number { let coords = this.color.coords; if (this.isLegacy) { - coords = this.color.to('srgb').coords.map(coordToRgb) as [ + coords = this.color.to('srgb').coords.map(coordToRgb).map(fuzzyRound) as [ number, number, number, From 435cf5c2652d680f59446f2ebb00f7308fc0b634 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 3 Nov 2023 16:33:22 -0400 Subject: [PATCH 11/25] track xyz as its own space, not an alias --- lib/src/value/color.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 9d50d9ed..fd95209f 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -152,10 +152,16 @@ function encodeSpaceForColorJs(space?: KnownColorSpace) { return space; } -function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { +function decodeSpaceFromColorJs( + space: string, + isRgb = false, + isXyz = false +): KnownColorSpace { switch (space) { case 'srgb': return isRgb ? 'rgb' : space; + case 'xyz-d65': + return isXyz ? 'xyz' : space; case 'a98rgb': return 'a98-rgb'; case 'p3': @@ -170,6 +176,7 @@ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { export class SassColor extends Value { private color: ColorType; private isRgb = false; + private isXyz = false; private channel0Id: string; private channel1Id: string; private channel2Id: string; @@ -200,6 +207,9 @@ export class SassColor extends Value { if (space === 'rgb') { this.isRgb = true; } + if (space === 'xyz') { + this.isXyz = true; + } let alpha; if (options.alpha === null) { if (!options.space) { @@ -346,7 +356,7 @@ export class SassColor extends Value { /** `this`'s color space. */ get space(): KnownColorSpace { - return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb); + return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb, this.isXyz); } /** Whether `this` is in a legacy color space. */ @@ -442,6 +452,7 @@ export class SassColor extends Value { _toSpaceInternal(space: KnownColorSpace) { this.isRgb = space === 'rgb'; + this.isXyz = space === 'xyz'; this.color = this.color.to(encodeSpaceForColorJs(space) as string); } @@ -498,6 +509,7 @@ export class SassColor extends Value { channel(channel: ChannelNameRgb, options: {space: ColorSpaceRgb}): number; channel(channel: ChannelNameXyz, options: {space: ColorSpaceXyz}): number; channel(channel: ChannelName, options?: {space: KnownColorSpace}): number { + if (channel === 'alpha') return this.alpha; let val: number; const space = options?.space ?? this.space; if (options?.space) { @@ -508,7 +520,7 @@ export class SassColor extends Value { } else { val = this.color.get(channel); } - if (space === 'rgb' && channel !== 'alpha') { + if (space === 'rgb') { val = coordToRgb(val); } return NaNtoZero(val); From dca7444a7be4e40a868c9fb8d2e742887d6e82be Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 3 Nov 2023 17:59:28 -0400 Subject: [PATCH 12/25] more tests passing --- lib/src/value/color.ts | 50 +++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index fd95209f..a2962dff 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -152,16 +152,12 @@ function encodeSpaceForColorJs(space?: KnownColorSpace) { return space; } -function decodeSpaceFromColorJs( - space: string, - isRgb = false, - isXyz = false -): KnownColorSpace { +function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { switch (space) { case 'srgb': return isRgb ? 'rgb' : space; case 'xyz-d65': - return isXyz ? 'xyz' : space; + return 'xyz'; case 'a98rgb': return 'a98-rgb'; case 'p3': @@ -172,11 +168,18 @@ function decodeSpaceFromColorJs( return space as KnownColorSpace; } +// @TODO For some spaces (e.g. Lab and Oklab), ColorJS only accepts `l` and not +// `lightness` as a channel name. Maybe a bug? +function encodeChannelForColorJs(channel: ChannelName) { + if (channel === 'lightness') return 'l'; + return channel; +} + /** A SassScript color. */ export class SassColor extends Value { private color: ColorType; private isRgb = false; - private isXyz = false; + private alphaMissing = false; private channel0Id: string; private channel1Id: string; private channel2Id: string; @@ -207,15 +210,13 @@ export class SassColor extends Value { if (space === 'rgb') { this.isRgb = true; } - if (space === 'xyz') { - this.isXyz = true; - } let alpha; if (options.alpha === null) { if (!options.space) { emitNullAlphaDeprecation(); } alpha = NaN; + this.alphaMissing = true; } else if (options.alpha === undefined) { alpha = 1; } else { @@ -347,6 +348,11 @@ export class SassColor extends Value { }); break; } + + // @TODO ColorJS doesn't seem to allow initial `alpha` to be missing? + if (this.alphaMissing) { + this.color.alpha = NaN; + } } /** `this`'s alpha channel. */ @@ -356,7 +362,7 @@ export class SassColor extends Value { /** `this`'s color space. */ get space(): KnownColorSpace { - return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb, this.isXyz); + return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb); } /** Whether `this` is in a legacy color space. */ @@ -452,7 +458,6 @@ export class SassColor extends Value { _toSpaceInternal(space: KnownColorSpace) { this.isRgb = space === 'rgb'; - this.isXyz = space === 'xyz'; this.color = this.color.to(encodeSpaceForColorJs(space) as string); } @@ -510,15 +515,20 @@ export class SassColor extends Value { channel(channel: ChannelNameXyz, options: {space: ColorSpaceXyz}): number; channel(channel: ChannelName, options?: {space: KnownColorSpace}): number { if (channel === 'alpha') return this.alpha; + // @TODO check that channel exists in space, or throw + // checkChannelValid(); let val: number; const space = options?.space ?? this.space; if (options?.space) { val = this.color.get({ space: encodeSpaceForColorJs(options.space) as string, - coordId: channel, + coordId: encodeChannelForColorJs(channel), }); } else { - val = this.color.get(channel); + val = this.color.get({ + space: this.color.spaceId, + coordId: encodeChannelForColorJs(channel), + }); } if (space === 'rgb') { val = coordToRgb(val); @@ -533,7 +543,15 @@ export class SassColor extends Value { * [missing channel]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components */ isChannelMissing(channel: ChannelName): boolean { - return Number.isNaN(this.color.get(channel)); + if (channel === 'alpha') return Number.isNaN(this.color.alpha); + // @TODO check that channel exists in space, or throw + // checkChannelValid(); + return Number.isNaN( + this.color.get({ + space: this.color.spaceId, + coordId: encodeChannelForColorJs(channel), + }) + ); } /** @@ -574,6 +592,8 @@ export class SassColor extends Value { options?: {space: KnownColorSpace} ): boolean { if (channel === 'alpha') return false; + // @TODO check that channel exists in space, or throw + // checkChannelValid(); const color = options?.space ? this.toSpace(options.space) : this; const channels = color.channels.toArray(); switch (channel) { From 133dd7b52e26489de72f55e7032c4cad211077e6 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 3 Nov 2023 20:52:32 -0400 Subject: [PATCH 13/25] Include space in hash code --- lib/src/value/color.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index a2962dff..833865e0 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -11,7 +11,7 @@ import { fuzzyRound, positiveMod, } from './utils'; -import {List} from 'immutable'; +import {List, hash} from 'immutable'; import Color from 'colorjs.io'; import type ColorType from 'colorjs.io'; @@ -754,6 +754,7 @@ export class SassColor extends Value { ); } return ( + hash(this.space) ^ fuzzyHashCode(coords[0]) ^ fuzzyHashCode(coords[1]) ^ fuzzyHashCode(coords[2]) ^ From 71d5f203fc8f234cff9df446c972aa25776a3908 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 3 Nov 2023 21:05:53 -0400 Subject: [PATCH 14/25] Always fuzzyRound red/green/blue getters --- lib/src/value/color.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 833865e0..aaf79aee 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -395,31 +395,22 @@ export class SassColor extends Value { /** `this`'s red channel. */ get red(): number { emitColor4ApiDeprecation('red'); - let val = NaNtoZero(coordToRgb(this.color.srgb.red)); - if (this.isLegacy && this.space !== 'rgb') { - val = fuzzyRound(val); - } - return val; + const val = NaNtoZero(coordToRgb(this.color.srgb.red)); + return fuzzyRound(val); } /** `this`'s blue channel. */ get blue(): number { emitColor4ApiDeprecation('blue'); - let val = NaNtoZero(coordToRgb(this.color.srgb.blue)); - if (this.isLegacy && this.space !== 'rgb') { - val = fuzzyRound(val); - } - return val; + const val = NaNtoZero(coordToRgb(this.color.srgb.blue)); + return fuzzyRound(val); } /** `this`'s green channel. */ get green(): number { emitColor4ApiDeprecation('green'); - let val = NaNtoZero(coordToRgb(this.color.srgb.green)); - if (this.isLegacy && this.space !== 'rgb') { - val = fuzzyRound(val); - } - return val; + const val = NaNtoZero(coordToRgb(this.color.srgb.green)); + return fuzzyRound(val); } /** `this`'s hue value. */ From c04411bd45fbd3055dd882e34acaa25bffec0990 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 6 Nov 2023 12:13:13 -0500 Subject: [PATCH 15/25] Update powerless and check for channel validity --- lib/src/value/color.ts | 74 +++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index aaf79aee..ed4a73de 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -175,6 +175,49 @@ function encodeChannelForColorJs(channel: ChannelName) { return channel; } +// Implement our own check of channel name validity for a given space, because +// ColorJS allows e.g. `b` for either `blue` or `blackness` or `b` channels. +function checkChannelValid(channel: ChannelName, space: KnownColorSpace) { + let valid = false; + switch (space) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + valid = ['red', 'green', 'blue'].includes(channel); + break; + case 'hsl': + valid = ['hue', 'saturation', 'lightness'].includes(channel); + break; + case 'hwb': + valid = ['hue', 'whiteness', 'blackness'].includes(channel); + break; + case 'lab': + case 'oklab': + valid = ['lightness', 'a', 'b'].includes(channel); + break; + case 'lch': + case 'oklch': + valid = ['lightness', 'chroma', 'hue'].includes(channel); + break; + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + valid = ['x', 'y', 'z'].includes(channel); + break; + } + if (!valid) { + throw valueError( + `Unknown channel name "${channel}" for color space "${space}".` + ); + } +} + +Color.defaults.precision = 15; + /** A SassScript color. */ export class SassColor extends Value { private color: ColorType; @@ -506,10 +549,9 @@ export class SassColor extends Value { channel(channel: ChannelNameXyz, options: {space: ColorSpaceXyz}): number; channel(channel: ChannelName, options?: {space: KnownColorSpace}): number { if (channel === 'alpha') return this.alpha; - // @TODO check that channel exists in space, or throw - // checkChannelValid(); let val: number; const space = options?.space ?? this.space; + checkChannelValid(channel, space); if (options?.space) { val = this.color.get({ space: encodeSpaceForColorJs(options.space) as string, @@ -535,8 +577,7 @@ export class SassColor extends Value { */ isChannelMissing(channel: ChannelName): boolean { if (channel === 'alpha') return Number.isNaN(this.color.alpha); - // @TODO check that channel exists in space, or throw - // checkChannelValid(); + checkChannelValid(channel, this.space); return Number.isNaN( this.color.get({ space: this.color.spaceId, @@ -583,42 +624,23 @@ export class SassColor extends Value { options?: {space: KnownColorSpace} ): boolean { if (channel === 'alpha') return false; - // @TODO check that channel exists in space, or throw - // checkChannelValid(); const color = options?.space ? this.toSpace(options.space) : this; + checkChannelValid(channel, color.space); const channels = color.channels.toArray(); switch (channel) { case color.channel0Id: if (color.space === 'hsl') { - return fuzzyEquals(channels[1], 0) || fuzzyEquals(channels[2], 0); + return fuzzyEquals(channels[1], 0); } if (color.space === 'hwb') { return fuzzyEquals(channels[1] + channels[2], 100); } return false; - case color.channel1Id: - switch (color.space) { - case 'hsl': - return fuzzyEquals(channels[2], 0); - case 'lab': - case 'oklab': - case 'lch': - case 'oklch': - return fuzzyEquals(channels[0], 0) || fuzzyEquals(channels[0], 100); - } - return false; case color.channel2Id: switch (color.space) { - case 'lab': - case 'oklab': - return fuzzyEquals(channels[0], 0) || fuzzyEquals(channels[0], 100); case 'lch': case 'oklch': - return ( - fuzzyEquals(channels[0], 0) || - fuzzyEquals(channels[0], 100) || - fuzzyEquals(channels[1], 0) - ); + return fuzzyEquals(channels[1], 0); } return false; } From 89cb50e41db6097276106042783f7709e81e35ca Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 6 Nov 2023 16:27:30 -0500 Subject: [PATCH 16/25] Add interpolate implementation --- lib/src/value/color.ts | 296 +++++++++++++++++++++++++++++++---------- 1 file changed, 223 insertions(+), 73 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index ed4a73de..137d93e4 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -76,21 +76,25 @@ export type KnownColorSpace = | ColorSpaceRgb | ColorSpaceXyz; +/** Polar color space names (HSL, HWB, LCH, and Oklch spaces). */ +type PolarColorSpace = ColorSpaceHsl | ColorSpaceHwb | ColorSpaceLch; + /** * Methods by which two hues are adjusted when interpolating between polar * colors. */ -export type HueInterpolationMethod = +type HueInterpolationMethod = | 'decreasing' | 'increasing' | 'longer' | 'shorter'; -type Channels = { +type ChannelOptions = { [key in ChannelName]?: number | null; }; -function getColorSpace(options: Channels) { +/** Legacy determination of color space by channel name. */ +function getColorSpace(options: ChannelOptions): KnownColorSpace { if (typeof options.red === 'number') { return 'rgb'; } @@ -103,42 +107,36 @@ function getColorSpace(options: Channels) { throw valueError('No color space found'); } -function emitColor4ApiDeprecation(name: string) { - console.warn( - `Deprecation [color-4-api]: \`${name}\` is deprecated; use \`channel\` instead.` - ); -} - -function emitNullAlphaDeprecation() { - console.warn( - 'Deprecation [null-alpha]: ' + - 'Passing `alpha: null` without setting `space` is deprecated.' + - '\n' + - 'More info: https://sass-lang.com/d/null-alpha' - ); -} - -function NaNtoNull(val: number) { +function NaNtoNull(val: number): number | null { return Number.isNaN(val) ? null : val; } -function NaNtoZero(val: number) { +function NaNtoZero(val: number): number { return Number.isNaN(val) ? 0 : val; } -function assertClamped(val: number, min: number, max: number, name: string) { +function assertClamped( + val: number, + min: number, + max: number, + name: string +): number { return Number.isNaN(val) ? val : fuzzyAssertInRange(val, min, max, name); } -function coordToRgb(val: number) { +function coordToRgb(val: number): number { return val * 255; } -function normalizeHue(val: number) { +function normalizeHue(val: number): number { return positiveMod(val, 360); } -function encodeSpaceForColorJs(space?: KnownColorSpace) { +/** + * Normalize discrepancies between Sass color spaces and ColorJS color space + * ids. + */ +function encodeSpaceForColorJs(space?: KnownColorSpace): string | undefined { switch (space) { case 'rgb': return 'srgb'; @@ -152,6 +150,10 @@ function encodeSpaceForColorJs(space?: KnownColorSpace) { return space; } +/** + * Normalize discrepancies between Sass color spaces and ColorJS color space + * ids. + */ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { switch (space) { case 'srgb': @@ -170,14 +172,22 @@ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { // @TODO For some spaces (e.g. Lab and Oklab), ColorJS only accepts `l` and not // `lightness` as a channel name. Maybe a bug? -function encodeChannelForColorJs(channel: ChannelName) { +/** + * Normalize discrepancies between Sass channel names and ColorJS channel ids. + */ +function encodeChannelForColorJs(channel: ChannelName): string { if (channel === 'lightness') return 'l'; return channel; } -// Implement our own check of channel name validity for a given space, because -// ColorJS allows e.g. `b` for either `blue` or `blackness` or `b` channels. -function checkChannelValid(channel: ChannelName, space: KnownColorSpace) { +/** + * Implement our own check of channel name validity for a given space, because + * ColorJS allows e.g. `b` for either `blue` or `blackness` or `b` channels. + */ +function validateChannelInSpace( + channel: ChannelName, + space: KnownColorSpace +): void { let valid = false; switch (space) { case 'rgb': @@ -216,30 +226,85 @@ function checkChannelValid(channel: ChannelName, space: KnownColorSpace) { } } +/** Determine whether the given space is a polar color space. */ +function isPolarColorSpace(space: KnownColorSpace): space is PolarColorSpace { + switch (space) { + case 'hsl': + case 'hwb': + case 'lch': + case 'oklch': + return true; + default: + return false; + } +} + +/** + * Normalize between ColorJS coordinates (which use `NaN`) and Sass Color + * coordinates (which use `null`). + */ +function getCoordsFromColor( + coords: [number, number, number], + isRgb = false +): [number | null, number | null, number | null] { + let newCoords: [number | null, number | null, number | null] = coords; + if (isRgb) { + newCoords = (newCoords as [number, number, number]).map(coordToRgb) as [ + number, + number, + number, + ]; + } + return (newCoords as [number, number, number]).map(NaNtoNull) as [ + number | null, + number | null, + number | null, + ]; +} + +function emitColor4ApiDeprecation(name: string) { + console.warn( + `Deprecation [color-4-api]: \`${name}\` is deprecated; use \`channel\` instead.` + ); +} + +function emitNullAlphaDeprecation() { + console.warn( + 'Deprecation [null-alpha]: ' + + 'Passing `alpha: null` without setting `space` is deprecated.' + + '\n' + + 'More info: https://sass-lang.com/d/null-alpha' + ); +} + +// @TODO remove this Color.defaults.precision = 15; /** A SassScript color. */ export class SassColor extends Value { + // ColorJS color object private color: ColorType; + // Boolean indicating whether this color is in RGB format private isRgb = false; + // Boolean indicating whether this color has a missing `alpha` channel private alphaMissing = false; - private channel0Id: string; - private channel1Id: string; - private channel2Id: string; - private clone() { - let coords = this.color.coords; - if (this.space === 'rgb') { - coords = coords.map(coordToRgb) as [number, number, number]; - } + // Names for the channels of this color + private channel0Id: ChannelName; + private channel1Id: ChannelName; + private channel2Id: ChannelName; + // Private method for cloning this as a new SassColor + private clone(): SassColor { + const coords = getCoordsFromColor(this.color.coords, this.space === 'rgb'); return new SassColor({ space: this.space, [this.channel0Id]: coords[0], [this.channel1Id]: coords[1], [this.channel2Id]: coords[2], + alpha: NaNtoNull(this.color.alpha), }); } - constructor(options: Channels & {space?: KnownColorSpace}) { + constructor(options: ChannelOptions & {space?: KnownColorSpace}) { super(); if (options.alpha === null && !options.space) { @@ -398,25 +463,29 @@ export class SassColor extends Value { } } - /** `this`'s alpha channel. */ + /** This color's alpha channel, between `0` and `1`. */ get alpha(): number { return NaNtoZero(this.color.alpha); } - /** `this`'s color space. */ + /** The name of this color's color space. */ get space(): KnownColorSpace { return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb); } - /** Whether `this` is in a legacy color space. */ + /** + * A boolean indicating whether this color is in a legacy color space (`rgb`, + * `hsl`, or `hwb`). + */ get isLegacy(): boolean { return ['rgb', 'hsl', 'hwb'].includes(this.space); } - /** The values of this color's channels (excluding the alpha channel), or - * `null` for [missing] channels. + /** + * A list of this color's channel values (excluding alpha), with [missing + * channels] converted to `null`. * - * [missing]: https://www.w3.org/TR/css-color-4/#missing + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components */ get channelsOrNull(): List { let coords = this.color.coords; @@ -426,7 +495,12 @@ export class SassColor extends Value { return List(coords.map(NaNtoNull)); } - /** The values of this color's channels (excluding the alpha channel). */ + /** + * A list of this color's channel values (excluding alpha), with [missing + * channels] converted to `0`. + * + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ get channels(): List { let coords = this.color.coords; if (this.space === 'rgb') { @@ -435,52 +509,84 @@ export class SassColor extends Value { return List(coords.map(NaNtoZero)); } - /** `this`'s red channel. */ + /** + * This color's red channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ get red(): number { emitColor4ApiDeprecation('red'); const val = NaNtoZero(coordToRgb(this.color.srgb.red)); return fuzzyRound(val); } - /** `this`'s blue channel. */ - get blue(): number { - emitColor4ApiDeprecation('blue'); - const val = NaNtoZero(coordToRgb(this.color.srgb.blue)); - return fuzzyRound(val); - } - - /** `this`'s green channel. */ + /** + * This color's green channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ get green(): number { emitColor4ApiDeprecation('green'); const val = NaNtoZero(coordToRgb(this.color.srgb.green)); return fuzzyRound(val); } - /** `this`'s hue value. */ + /** + * This color's blue channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ + get blue(): number { + emitColor4ApiDeprecation('blue'); + const val = NaNtoZero(coordToRgb(this.color.srgb.blue)); + return fuzzyRound(val); + } + + /** + * This color's hue in the HSL color space, between `0` and `360`. + * + * @deprecated Use {@link channel} instead. + */ get hue(): number { emitColor4ApiDeprecation('hue'); return NaNtoZero(this.color.hsl.hue); } - /** `this`'s saturation value. */ + /** + * This color's saturation in the HSL color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get saturation(): number { emitColor4ApiDeprecation('saturation'); return NaNtoZero(this.color.hsl.saturation); } - /** `this`'s lightness value. */ + /** + * This color's lightness in the HSL color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get lightness(): number { emitColor4ApiDeprecation('lightness'); return NaNtoZero(this.color.hsl.lightness); } - /** `this`'s whiteness value. */ + /** + * This color's whiteness in the HWB color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get whiteness(): number { emitColor4ApiDeprecation('whiteness'); return NaNtoZero(this.color.hwb.whiteness); } - /** `this`'s blackness value. */ + /** + * This color's blackness in the HWB color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get blackness(): number { emitColor4ApiDeprecation('blackness'); return NaNtoZero(this.color.hwb.blackness); @@ -490,7 +596,8 @@ export class SassColor extends Value { return this; } - _toSpaceInternal(space: KnownColorSpace) { + // Internal helper which mutates the current color object. + _toSpaceInternal(space: KnownColorSpace): void { this.isRgb = space === 'rgb'; this.color = this.color.to(encodeSpaceForColorJs(space) as string); } @@ -514,7 +621,8 @@ export class SassColor extends Value { return this.color.inGamut(encodeSpaceForColorJs(space)); } - _toGamutInternal(space?: KnownColorSpace) { + // Internal helper which mutates the current color object. + _toGamutInternal(space?: KnownColorSpace): void { this.color.toGamut({space: encodeSpaceForColorJs(space)}); } @@ -551,7 +659,7 @@ export class SassColor extends Value { if (channel === 'alpha') return this.alpha; let val: number; const space = options?.space ?? this.space; - checkChannelValid(channel, space); + validateChannelInSpace(channel, space); if (options?.space) { val = this.color.get({ space: encodeSpaceForColorJs(options.space) as string, @@ -577,7 +685,7 @@ export class SassColor extends Value { */ isChannelMissing(channel: ChannelName): boolean { if (channel === 'alpha') return Number.isNaN(this.color.alpha); - checkChannelValid(channel, this.space); + validateChannelInSpace(channel, this.space); return Number.isNaN( this.color.get({ space: this.color.spaceId, @@ -625,7 +733,7 @@ export class SassColor extends Value { ): boolean { if (channel === 'alpha') return false; const color = options?.space ? this.toSpace(options.space) : this; - checkChannelValid(channel, color.space); + validateChannelInSpace(channel, color.space); const channels = color.channels.toArray(); switch (channel) { case color.channel0Id: @@ -647,7 +755,58 @@ export class SassColor extends Value { return false; } - // TODO(jgerigmeyer): Temp fns to pass type checks + /** + * Returns a color partway between this color and `color2` according to + * `method`, as defined by the CSS Color 4 [color interpolation] procedure. + * + * [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation + * + * If `method` is missing and this color is in a rectangular color space (Lab, + * Oklab, RGB, and XYZ spaces), `method` defaults to the color space of this + * color. Otherwise, `method` defaults to a space separated list containing + * the color space of this color and the string "shorter". + * + * The `weight` is a number between 0 and 1 that indicates how much of this + * color should be in the resulting color. If omitted, it defaults to 0.5. + */ + interpolate( + color2: SassColor, + options?: { + weight?: number; + method?: HueInterpolationMethod; + } + ): SassColor { + const hueInterpolationMethod = + options?.method ?? + (isPolarColorSpace(this.space) ? 'shorter' : undefined); + const weight = options?.weight ?? 0.5; + + if (fuzzyEquals(weight, 0)) return color2; + if (fuzzyEquals(weight, 1)) return this; + + if (weight < 0 || weight > 1) { + throw valueError( + `Expected \`weight\` between \`0\` and \`1\`; received \`${weight}\`.` + ); + } + + // ColorJS inverses the `weight` argument, where `0` is `this` and `1` is + // `color2`. + const color = this.color.mix(color2.color, 1 - weight, { + space: encodeSpaceForColorJs(this.space), + hue: hueInterpolationMethod, + } as any); // @TODO Waiting on new ColorJS release to fix type defs + const coords = getCoordsFromColor(color.coords, this.space === 'rgb'); + return new SassColor({ + space: this.space, + [this.channel0Id]: coords[0], + [this.channel1Id]: coords[1], + [this.channel2Id]: coords[2], + alpha: NaNtoNull(this.color.alpha), + }); + } + + // TODO(jgerigmeyer): Temp fn to pass type checks change( options: { [key in ChannelName]?: number | null; @@ -657,15 +816,6 @@ export class SassColor extends Value { ) { return this; } - interpolate( - color2: SassColor, - options: { - weight?: number; - method?: HueInterpolationMethod; - } - ) { - return this; - } /** * Returns a copy of `this` with its channels changed to match `color`. From afcac77a1f17758938cf24e740baedd99cf0b31e Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 6 Nov 2023 16:46:22 -0500 Subject: [PATCH 17/25] Try using colorjs legacy build --- lib/@types/colorjs.io.d.ts | 7 +++++++ lib/src/value/color.ts | 10 +++------- 2 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 lib/@types/colorjs.io.d.ts diff --git a/lib/@types/colorjs.io.d.ts b/lib/@types/colorjs.io.d.ts new file mode 100644 index 00000000..50e4938d --- /dev/null +++ b/lib/@types/colorjs.io.d.ts @@ -0,0 +1,7 @@ +// We need the legacy build to support Node14, but ColorJS does not export types +// with the legacy build -- so we point one to the other. +declare module 'colorjs.io/dist/color.legacy' { + import Color from 'colorjs.io'; + + export default Color; +} diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 137d93e4..7cc3feeb 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -12,7 +12,7 @@ import { positiveMod, } from './utils'; import {List, hash} from 'immutable'; -import Color from 'colorjs.io'; +import Color from 'colorjs.io/dist/color.legacy'; import type ColorType from 'colorjs.io'; /** The HSL color space name. */ @@ -648,7 +648,6 @@ export class SassColor extends Value { * * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components */ - channel(channel: ChannelName): number; channel(channel: ChannelNameHsl, options: {space: ColorSpaceHsl}): number; channel(channel: ChannelNameHwb, options: {space: ColorSpaceHwb}): number; channel(channel: ChannelNameLab, options: {space: ColorSpaceLab}): number; @@ -702,7 +701,6 @@ export class SassColor extends Value { * * [powerless]: https://www.w3.org/TR/css-color-4/#powerless */ - isChannelPowerless(channel: ChannelName): boolean; isChannelPowerless( channel: ChannelNameHsl, options?: {space: ColorSpaceHsl} @@ -761,10 +759,8 @@ export class SassColor extends Value { * * [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation * - * If `method` is missing and this color is in a rectangular color space (Lab, - * Oklab, RGB, and XYZ spaces), `method` defaults to the color space of this - * color. Otherwise, `method` defaults to a space separated list containing - * the color space of this color and the string "shorter". + * If `method` is missing and this color is in a polar color space (HSL, HWB, + * LCH, and Oklch spaces), `method` defaults to "shorter". * * The `weight` is a number between 0 and 1 that indicates how much of this * color should be in the resulting color. If omitted, it defaults to 0.5. From 4129510c3b959c9fb53e03df592108e4f106f077 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 6 Nov 2023 16:56:34 -0500 Subject: [PATCH 18/25] re-add missing type def --- lib/src/value/color.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 7cc3feeb..9501bbdd 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -648,6 +648,7 @@ export class SassColor extends Value { * * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components */ + channel(channel: ChannelName): number; channel(channel: ChannelNameHsl, options: {space: ColorSpaceHsl}): number; channel(channel: ChannelNameHwb, options: {space: ColorSpaceHwb}): number; channel(channel: ChannelNameLab, options: {space: ColorSpaceLab}): number; From 193761f0666bf4ed689f5d92573dbf7b9d5ff030 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 6 Nov 2023 17:01:52 -0500 Subject: [PATCH 19/25] Try cjs build? --- lib/@types/colorjs.io.d.ts | 2 +- lib/src/value/color.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/@types/colorjs.io.d.ts b/lib/@types/colorjs.io.d.ts index 50e4938d..2a84a5ab 100644 --- a/lib/@types/colorjs.io.d.ts +++ b/lib/@types/colorjs.io.d.ts @@ -1,6 +1,6 @@ // We need the legacy build to support Node14, but ColorJS does not export types // with the legacy build -- so we point one to the other. -declare module 'colorjs.io/dist/color.legacy' { +declare module 'colorjs.io/dist/color.legacy.cjs' { import Color from 'colorjs.io'; export default Color; diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 9501bbdd..2c529fa5 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -12,7 +12,7 @@ import { positiveMod, } from './utils'; import {List, hash} from 'immutable'; -import Color from 'colorjs.io/dist/color.legacy'; +import Color from 'colorjs.io/dist/color.legacy.cjs'; import type ColorType from 'colorjs.io'; /** The HSL color space name. */ From c0514bd07398bd55ad4ba05178a5c0cc7cb89733 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Thu, 9 Nov 2023 17:27:43 -0500 Subject: [PATCH 20/25] Implement color.change --- lib/src/value/color.ts | 341 ++++++++++++++++++++++++++++++++--------- 1 file changed, 265 insertions(+), 76 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 2c529fa5..30530af2 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -188,6 +188,7 @@ function validateChannelInSpace( channel: ChannelName, space: KnownColorSpace ): void { + if (channel === 'alpha') return; let valid = false; switch (space) { case 'rgb': @@ -262,12 +263,47 @@ function getCoordsFromColor( ]; } -function emitColor4ApiDeprecation(name: string) { +function propertyIsSet(val: undefined | null | number): val is number | null { + return val === null || typeof val === 'number'; +} + +function checkChangeDeprecations( + options: { + [key in ChannelName]?: number | null; + }, + channels: ChannelName[] +) { + if (options.alpha === null) { + emitNullAlphaDeprecation(); + } + for (const channel of channels) { + if (options[channel] === null) { + emitColor4ApiChangeNullDeprecation(channel); + } + } +} + +function emitColor4ApiGetterDeprecation(name: string) { console.warn( `Deprecation [color-4-api]: \`${name}\` is deprecated; use \`channel\` instead.` ); } +function emitColor4ApiChangeSpaceDeprecation() { + console.warn( + 'Deprecation [color-4-api]: ' + + "Changing a channel not in this color's space without explicitly " + + 'specifying the `space` option is deprecated.' + ); +} + +function emitColor4ApiChangeNullDeprecation(channel: string) { + console.warn( + 'Deprecation [color-4-api]: ' + + `Passing \`${channel}: null\` without setting \`space\` is deprecated.` + ); +} + function emitNullAlphaDeprecation() { console.warn( 'Deprecation [null-alpha]: ' + @@ -307,13 +343,6 @@ export class SassColor extends Value { constructor(options: ChannelOptions & {space?: KnownColorSpace}) { super(); - if (options.alpha === null && !options.space) { - console.warn( - 'Passing `alpha: null` without setting `space` is deprecated.\n\n' + - 'More info: https://sass-lang.com/d/null-alpha' - ); - } - const space = options.space ?? getColorSpace(options); if (space === 'rgb') { this.isRgb = true; @@ -515,7 +544,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get red(): number { - emitColor4ApiDeprecation('red'); + emitColor4ApiGetterDeprecation('red'); const val = NaNtoZero(coordToRgb(this.color.srgb.red)); return fuzzyRound(val); } @@ -526,7 +555,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get green(): number { - emitColor4ApiDeprecation('green'); + emitColor4ApiGetterDeprecation('green'); const val = NaNtoZero(coordToRgb(this.color.srgb.green)); return fuzzyRound(val); } @@ -537,7 +566,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get blue(): number { - emitColor4ApiDeprecation('blue'); + emitColor4ApiGetterDeprecation('blue'); const val = NaNtoZero(coordToRgb(this.color.srgb.blue)); return fuzzyRound(val); } @@ -548,7 +577,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get hue(): number { - emitColor4ApiDeprecation('hue'); + emitColor4ApiGetterDeprecation('hue'); return NaNtoZero(this.color.hsl.hue); } @@ -558,7 +587,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get saturation(): number { - emitColor4ApiDeprecation('saturation'); + emitColor4ApiGetterDeprecation('saturation'); return NaNtoZero(this.color.hsl.saturation); } @@ -568,7 +597,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get lightness(): number { - emitColor4ApiDeprecation('lightness'); + emitColor4ApiGetterDeprecation('lightness'); return NaNtoZero(this.color.hsl.lightness); } @@ -578,7 +607,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get whiteness(): number { - emitColor4ApiDeprecation('whiteness'); + emitColor4ApiGetterDeprecation('whiteness'); return NaNtoZero(this.color.hwb.whiteness); } @@ -588,7 +617,7 @@ export class SassColor extends Value { * @deprecated Use {@link channel} instead. */ get blackness(): number { - emitColor4ApiDeprecation('blackness'); + emitColor4ApiGetterDeprecation('blackness'); return NaNtoZero(this.color.hwb.blackness); } @@ -603,7 +632,8 @@ export class SassColor extends Value { } /** - * Returns this color converted to the specified `space`. + * Returns a new color that's the result of converting this color to the + * specified `space`. */ toSpace(space: KnownColorSpace): SassColor { if (space === this.space) return this; @@ -627,12 +657,13 @@ export class SassColor extends Value { } /** - * Returns this color, modified so it is in-gamut for the specified `space`—or - * the current color space if `space` is not specified—using the recommended - * [CSS Gamut Mapping Algorithm][css-mapping] to map out-of-gamut colors into - * the desired gamut with as little perceptual change as possible. + * Returns a copy of this color, modified so it is in-gamut for the specified + * `space`—or the current color space if `space` is not specified—using the + * recommended [CSS Gamut Mapping Algorithm][css-mapping] to map out-of-gamut + * colors into the desired gamut with as little perceptual change as possible. * - * [css-mapping]: https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm + * [css-mapping]: + * https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm */ toGamut(space?: KnownColorSpace): SassColor { if (this.isInGamut(space)) return this; @@ -803,7 +834,52 @@ export class SassColor extends Value { }); } - // TODO(jgerigmeyer): Temp fn to pass type checks + /** + * Returns a new color that's the result of changing one or more of this + * color's channels. + */ + change( + options: { + [key in ChannelNameHsl]?: number | null; + } & { + space?: ColorSpaceHsl; + } + ): SassColor; + change( + options: { + [key in ChannelNameHwb]?: number | null; + } & { + space?: ColorSpaceHwb; + } + ): SassColor; + change( + options: { + [key in ChannelNameLab]?: number | null; + } & { + space?: ColorSpaceLab; + } + ): SassColor; + change( + options: { + [key in ChannelNameLch]?: number | null; + } & { + space?: ColorSpaceLch; + } + ): SassColor; + change( + options: { + [key in ChannelNameRgb]?: number | null; + } & { + space?: ColorSpaceRgb; + } + ): SassColor; + change( + options: { + [key in ChannelNameXyz]?: number | null; + } & { + space?: ColorSpaceXyz; + } + ): SassColor; change( options: { [key in ChannelName]?: number | null; @@ -811,60 +887,173 @@ export class SassColor extends Value { space?: KnownColorSpace; } ) { - return this; - } + const spaceSetExplicitly = !!options.space; + let space = options.space ?? this.space; + if (this.isLegacy && !spaceSetExplicitly) { + if ( + propertyIsSet(options.whiteness) || + propertyIsSet(options.blackness) || + (this.space === 'hwb' && propertyIsSet(options.hue)) + ) { + space = 'hwb'; + } else if ( + propertyIsSet(options.hue) || + propertyIsSet(options.saturation) || + propertyIsSet(options.lightness) + ) { + space = 'hsl'; + } else if ( + propertyIsSet(options.red) || + propertyIsSet(options.green) || + propertyIsSet(options.blue) + ) { + space = 'rgb'; + } + if (space !== this.space) { + emitColor4ApiChangeSpaceDeprecation(); + } + } - /** - * Returns a copy of `this` with its channels changed to match `color`. - */ - // change(color: Partial): SassColor; - // change(color: Partial): SassColor; - // change(color: Partial): SassColor; - // change( - // color: Partial | Partial | Partial - // ): SassColor { - // if ('whiteness' in color || 'blackness' in color) { - // return new SassColor({ - // hue: color.hue ?? this.hue, - // whiteness: color.whiteness ?? this.whiteness, - // blackness: color.blackness ?? this.blackness, - // alpha: color.alpha ?? this.alpha, - // }); - // } else if ( - // 'hue' in color || - // 'saturation' in color || - // 'lightness' in color - // ) { - // // Tell TypeScript this isn't a Partial. - // const hsl = color as Partial; - // return new SassColor({ - // hue: hsl.hue ?? this.hue, - // saturation: hsl.saturation ?? this.saturation, - // lightness: hsl.lightness ?? this.lightness, - // alpha: hsl.alpha ?? this.alpha, - // }); - // } else if ( - // 'red' in color || - // 'green' in color || - // 'blue' in color || - // this.redInternal - // ) { - // const rgb = color as Partial; - // return new SassColor({ - // red: rgb.red ?? this.red, - // green: rgb.green ?? this.green, - // blue: rgb.blue ?? this.blue, - // alpha: rgb.alpha ?? this.alpha, - // }); - // } else { - // return new SassColor({ - // hue: this.hue, - // saturation: this.saturation, - // lightness: this.lightness, - // alpha: color.alpha ?? this.alpha, - // }); - // } - // } + // Validate channel values + const keys = Object.keys(options).filter( + key => key !== 'space' + ) as ChannelName[]; + for (const channel of keys) { + validateChannelInSpace(channel, space); + } + if (propertyIsSet(options.alpha) && options.alpha !== null) { + fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); + } + if (propertyIsSet(options.lightness) && options.lightness !== null) { + const maxLightness = space === 'oklab' || space === 'oklch' ? 1 : 100; + assertClamped(options.lightness, 0, maxLightness, 'lightness'); + } + + const color = this.toSpace(space); + const getChangedValue = (channel: ChannelName) => { + if (propertyIsSet(options[channel])) { + return options[channel]; + } + return color.channel(channel); + }; + let changedColor: SassColor; + + switch (space) { + case 'hsl': + if (spaceSetExplicitly) { + changedColor = new SassColor({ + hue: getChangedValue('hue'), + saturation: getChangedValue('saturation'), + lightness: getChangedValue('lightness'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['hue', 'saturation', 'lightness']); + changedColor = new SassColor({ + hue: options.hue ?? color.channel('hue'), + saturation: options.saturation ?? color.channel('saturation'), + lightness: options.lightness ?? color.channel('lightness'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + break; + + case 'hwb': + if (spaceSetExplicitly) { + changedColor = new SassColor({ + hue: getChangedValue('hue'), + whiteness: getChangedValue('whiteness'), + blackness: getChangedValue('blackness'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['hue', 'whiteness', 'blackness']); + changedColor = new SassColor({ + hue: options.hue ?? color.channel('hue'), + whiteness: options.whiteness ?? color.channel('whiteness'), + blackness: options.blackness ?? color.channel('blackness'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + break; + + case 'rgb': + if (spaceSetExplicitly) { + changedColor = new SassColor({ + red: getChangedValue('red'), + green: getChangedValue('green'), + blue: getChangedValue('blue'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['red', 'green', 'blue']); + changedColor = new SassColor({ + red: options.red ?? color.channel('red'), + green: options.green ?? color.channel('green'), + blue: options.blue ?? color.channel('blue'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + break; + + case 'lab': + case 'oklab': + changedColor = new SassColor({ + lightness: getChangedValue('lightness'), + a: getChangedValue('a'), + b: getChangedValue('b'), + alpha: getChangedValue('alpha'), + space, + }); + break; + + case 'lch': + case 'oklch': + changedColor = new SassColor({ + lightness: getChangedValue('lightness'), + chroma: getChangedValue('chroma'), + hue: getChangedValue('hue'), + alpha: getChangedValue('alpha'), + space, + }); + break; + + case 'a98-rgb': + case 'display-p3': + case 'prophoto-rgb': + case 'rec2020': + case 'srgb': + case 'srgb-linear': + changedColor = new SassColor({ + red: getChangedValue('red'), + green: getChangedValue('green'), + blue: getChangedValue('blue'), + alpha: getChangedValue('alpha'), + space, + }); + break; + + case 'xyz': + case 'xyz-d50': + case 'xyz-d65': + changedColor = new SassColor({ + y: getChangedValue('y'), + x: getChangedValue('x'), + z: getChangedValue('z'), + alpha: getChangedValue('alpha'), + space, + }); + break; + } + + return changedColor.toSpace(this.space); + } equals(other: Value): boolean { if (!(other instanceof SassColor)) return false; From c19074d617f73b06e3de422904c0ffa31b956c8f Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 10 Nov 2023 10:18:07 -0500 Subject: [PATCH 21/25] Add missing space to protofy method --- lib/src/protofier.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index 68fe79eb..8b3acd7c 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -72,6 +72,7 @@ export class Protofier { color.channel2 = channels.get(1) as number; color.channel3 = channels.get(2) as number; color.alpha = value.alpha; + color.space = value.space; result.value = {case: 'color', value: color}; } else if (value instanceof SassList) { const list = new proto.Value_List(); From 0f479695c393273219d105c68ca08dce71ed6c88 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 10 Nov 2023 10:38:19 -0500 Subject: [PATCH 22/25] remove precision --- lib/src/value/color.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 30530af2..fba21dc1 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -313,9 +313,6 @@ function emitNullAlphaDeprecation() { ); } -// @TODO remove this -Color.defaults.precision = 15; - /** A SassScript color. */ export class SassColor extends Value { // ColorJS color object From a2853f928b24a6cba50dfcdcba2ed57ac7fff718 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 13 Nov 2023 13:54:10 -0500 Subject: [PATCH 23/25] Drop support for node v14. --- .github/workflows/ci.yml | 4 +--- lib/@types/colorjs.io.d.ts | 7 ------- lib/src/value/color.ts | 5 ++--- package.json | 2 +- tsconfig.json | 1 - 5 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 lib/@types/colorjs.io.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70129465..fc25b123 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: strategy: matrix: os: [ubuntu, macos, windows] - node-version: [18.x, 16.x, 14.x] # If changing this, also change env.DEFAULT_NODE_VERSION + node-version: [18.x, 16.x] # If changing this, also change env.DEFAULT_NODE_VERSION fail-fast: false steps: @@ -89,8 +89,6 @@ jobs: # Include LTS versions on Ubuntu - os: ubuntu node_version: 16 - - os: ubuntu - node_version: 14 steps: - uses: actions/checkout@v2 diff --git a/lib/@types/colorjs.io.d.ts b/lib/@types/colorjs.io.d.ts deleted file mode 100644 index 2a84a5ab..00000000 --- a/lib/@types/colorjs.io.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// We need the legacy build to support Node14, but ColorJS does not export types -// with the legacy build -- so we point one to the other. -declare module 'colorjs.io/dist/color.legacy.cjs' { - import Color from 'colorjs.io'; - - export default Color; -} diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index fba21dc1..989c7410 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -12,8 +12,7 @@ import { positiveMod, } from './utils'; import {List, hash} from 'immutable'; -import Color from 'colorjs.io/dist/color.legacy.cjs'; -import type ColorType from 'colorjs.io'; +import Color from 'colorjs.io'; /** The HSL color space name. */ type ColorSpaceHsl = 'hsl'; @@ -316,7 +315,7 @@ function emitNullAlphaDeprecation() { /** A SassScript color. */ export class SassColor extends Value { // ColorJS color object - private color: ColorType; + private color: Color; // Boolean indicating whether this color is in RGB format private isRgb = false; // Boolean indicating whether this color has a missing `alpha` channel diff --git a/package.json b/package.json index f61ec0d5..91b7afb3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dist/**/*" ], "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "scripts": { "init": "ts-node ./tool/init.ts", diff --git a/tsconfig.json b/tsconfig.json index c839cc1e..35577090 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "resolveJsonModule": true, "rootDir": ".", "useUnknownInCatchVariables": false, - "resolveJsonModule": true, "declaration": false, "lib": ["DOM"] }, From 18ab10ee84ce3b3321b8a8b98f52c7fb79650653 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 15 Nov 2023 11:59:10 -0500 Subject: [PATCH 24/25] Address review --- lib/src/value/color.ts | 522 +++++++++++++++++++++++------------------ lib/src/value/utils.ts | 2 +- 2 files changed, 294 insertions(+), 230 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 989c7410..28befc67 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -88,32 +88,45 @@ type HueInterpolationMethod = | 'longer' | 'shorter'; +/** Options for specifying any channel value. */ type ChannelOptions = { [key in ChannelName]?: number | null; }; +/** Constructor options for specifying space and/or channel values. */ +type ConstructorOptions = ChannelOptions & {space?: KnownColorSpace}; + +/** Constructor options for passing in existing ColorJS object and space. */ +type OptionsWithColor = {color: Color; space: KnownColorSpace}; + /** Legacy determination of color space by channel name. */ function getColorSpace(options: ChannelOptions): KnownColorSpace { - if (typeof options.red === 'number') { - return 'rgb'; - } - if (typeof options.saturation === 'number') { - return 'hsl'; - } - if (typeof options.whiteness === 'number') { - return 'hwb'; - } + if (typeof options.red === 'number') return 'rgb'; + if (typeof options.saturation === 'number') return 'hsl'; + if (typeof options.whiteness === 'number') return 'hwb'; throw valueError('No color space found'); } +/** + * Convert from the ColorJS representation of a missing component (`NaN`) to + * `null`. + */ function NaNtoNull(val: number): number | null { return Number.isNaN(val) ? null : val; } +/** + * Convert from the ColorJS representation of a missing component (`NaN`) to + * `0`. + */ function NaNtoZero(val: number): number { return Number.isNaN(val) ? 0 : val; } +/** + * Assert that `val` is either `NaN` or within `min` and `max`. Otherwise, + * throw an error. + */ function assertClamped( val: number, min: number, @@ -123,17 +136,19 @@ function assertClamped( return Number.isNaN(val) ? val : fuzzyAssertInRange(val, min, max, name); } +/** Convert from sRGB (0-1) to RGB (0-255) units. */ function coordToRgb(val: number): number { return val * 255; } +/** Normalize `hue` values to be within the range `[0, 360)`. */ function normalizeHue(val: number): number { return positiveMod(val, 360); } /** * Normalize discrepancies between Sass color spaces and ColorJS color space - * ids. + * ids, converting Sass values to ColorJS values. */ function encodeSpaceForColorJs(space?: KnownColorSpace): string | undefined { switch (space) { @@ -151,7 +166,7 @@ function encodeSpaceForColorJs(space?: KnownColorSpace): string | undefined { /** * Normalize discrepancies between Sass color spaces and ColorJS color space - * ids. + * ids, converting ColorJS values to Sass values. */ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { switch (space) { @@ -169,10 +184,13 @@ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { return space as KnownColorSpace; } -// @TODO For some spaces (e.g. Lab and Oklab), ColorJS only accepts `l` and not -// `lightness` as a channel name. Maybe a bug? /** - * Normalize discrepancies between Sass channel names and ColorJS channel ids. + * Normalize discrepancies between Sass channel names and ColorJS channel ids, + * converting Sass values to ColorJS values. + * + * For some spaces (e.g. Lab and Oklab), ColorJS only accepts `l` and not + * `lightness` as a channel name. This might be a bug: + * https://github.com/LeaVerou/color.js/issues/345. */ function encodeChannelForColorJs(channel: ChannelName): string { if (channel === 'lightness') return 'l'; @@ -181,7 +199,7 @@ function encodeChannelForColorJs(channel: ChannelName): string { /** * Implement our own check of channel name validity for a given space, because - * ColorJS allows e.g. `b` for either `blue` or `blackness` or `b` channels. + * ColorJS allows e.g. `b` for any of `blue`, `blackness`, or `b` channels. */ function validateChannelInSpace( channel: ChannelName, @@ -240,69 +258,82 @@ function isPolarColorSpace(space: KnownColorSpace): space is PolarColorSpace { } /** - * Normalize between ColorJS coordinates (which use `NaN`) and Sass Color - * coordinates (which use `null`). + * Convert from ColorJS coordinates (which use `NaN` for missing components, and + * a range of `0-1` for `rgb` channel values) to Sass Color coordinates (which + * use `null` for missing components, and a range of `0-255` for `rgb` channel + * values). */ -function getCoordsFromColor( - coords: [number, number, number], - isRgb = false +function decodeCoordsFromColorJs( + coords: [number, number, number], // ColorJS coordinates + isRgb = false // Whether this color is in the `rgb` color space ): [number | null, number | null, number | null] { - let newCoords: [number | null, number | null, number | null] = coords; - if (isRgb) { - newCoords = (newCoords as [number, number, number]).map(coordToRgb) as [ - number, - number, - number, - ]; - } - return (newCoords as [number, number, number]).map(NaNtoNull) as [ + let newCoords = coords; + // If this color is in the `rgb` space, convert channel values to `0-255` + if (isRgb) newCoords = newCoords.map(coordToRgb) as [number, number, number]; + // Convert `NaN` values to `null` + return newCoords.map(NaNtoNull) as [ number | null, number | null, number | null, ]; } -function propertyIsSet(val: undefined | null | number): val is number | null { +/** Returns `true` if `val` is a `number` or `null`. */ +function isNumberOrNull(val: undefined | null | number): val is number | null { return val === null || typeof val === 'number'; } +/** + * Emit deprecation warnings when legacy color spaces set `alpha` or channel + * values to `null` without explicitly setting the `space`. + */ function checkChangeDeprecations( options: { [key in ChannelName]?: number | null; }, channels: ChannelName[] ) { - if (options.alpha === null) { - emitNullAlphaDeprecation(); - } + if (options.alpha === null) emitNullAlphaDeprecation(); for (const channel of channels) { - if (options[channel] === null) { - emitColor4ApiChangeNullDeprecation(channel); - } + if (options[channel] === null) emitColor4ApiChangeNullDeprecation(channel); } } +/** Warn users about legacy color channel getters. */ function emitColor4ApiGetterDeprecation(name: string) { console.warn( - `Deprecation [color-4-api]: \`${name}\` is deprecated; use \`channel\` instead.` + 'Deprecation [color-4-api]: ' + + `\`${name}\` is deprecated, use \`channel\` instead.` + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' ); } +/** + * Warn users about changing channels not in the current color space without + * explicitly setting `space`. + */ function emitColor4ApiChangeSpaceDeprecation() { console.warn( 'Deprecation [color-4-api]: ' + "Changing a channel not in this color's space without explicitly " + - 'specifying the `space` option is deprecated.' + 'specifying the `space` option is deprecated.' + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' ); } +/** Warn users about `null` channel values without setting `space`. */ function emitColor4ApiChangeNullDeprecation(channel: string) { console.warn( 'Deprecation [color-4-api]: ' + - `Passing \`${channel}: null\` without setting \`space\` is deprecated.` + `Passing \`${channel}: null\` without setting \`space\` is deprecated.` + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' ); } +/** Warn users about null-alpha deprecation. */ function emitNullAlphaDeprecation() { console.warn( 'Deprecation [null-alpha]: ' + @@ -312,44 +343,110 @@ function emitNullAlphaDeprecation() { ); } +/** + * Determines whether the options passed to the Constructor include an existing + * ColorJS color object. + */ +function optionsHaveColor( + opts: OptionsWithColor | ConstructorOptions +): opts is OptionsWithColor { + return (opts as OptionsWithColor).color instanceof Color; +} + /** A SassScript color. */ export class SassColor extends Value { // ColorJS color object - private color: Color; + private readonly color: Color; + // Boolean indicating whether this color is in RGB format - private isRgb = false; - // Boolean indicating whether this color has a missing `alpha` channel - private alphaMissing = false; + // + // ColorJS treats `rgb` as an output format of the `srgb` color space, while + // Sass treats it as its own color space. By internally tracking whether this + // color is `rgb` or not, we can use `srgb` consistently for ColorJS while + // still returning expected `rgb` values for Sass users. + private readonly isRgb: boolean = false; + // Names for the channels of this color - private channel0Id: ChannelName; - private channel1Id: ChannelName; - private channel2Id: ChannelName; - // Private method for cloning this as a new SassColor - private clone(): SassColor { - const coords = getCoordsFromColor(this.color.coords, this.space === 'rgb'); - return new SassColor({ - space: this.space, - [this.channel0Id]: coords[0], - [this.channel1Id]: coords[1], - [this.channel2Id]: coords[2], - alpha: NaNtoNull(this.color.alpha), - }); + private channel0Id!: ChannelName; + private channel1Id!: ChannelName; + private channel2Id!: ChannelName; + + // Sets channel names based on this color's color space + private setChannelIds(space: KnownColorSpace) { + switch (space) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + this.channel0Id = 'red'; + this.channel1Id = 'green'; + this.channel2Id = 'blue'; + break; + + case 'hsl': + this.channel0Id = 'hue'; + this.channel1Id = 'saturation'; + this.channel2Id = 'lightness'; + break; + + case 'hwb': + this.channel0Id = 'hue'; + this.channel1Id = 'whiteness'; + this.channel2Id = 'blackness'; + break; + + case 'lab': + case 'oklab': + this.channel0Id = 'lightness'; + this.channel1Id = 'a'; + this.channel2Id = 'b'; + break; + + case 'lch': + case 'oklch': + this.channel0Id = 'lightness'; + this.channel1Id = 'chroma'; + this.channel2Id = 'hue'; + break; + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + this.channel0Id = 'x'; + this.channel1Id = 'y'; + this.channel2Id = 'z'; + break; + } } - constructor(options: ChannelOptions & {space?: KnownColorSpace}) { + constructor(options: OptionsWithColor); + constructor(options: ConstructorOptions); + constructor(optionsMaybeWithColor: OptionsWithColor | ConstructorOptions) { super(); - const space = options.space ?? getColorSpace(options); - if (space === 'rgb') { - this.isRgb = true; + let options: ConstructorOptions; + + // Use existing ColorJS color object from options for the new SassColor + if (optionsHaveColor(optionsMaybeWithColor)) { + const {color, space} = optionsMaybeWithColor; + if (space === 'rgb') this.isRgb = true; + this.setChannelIds(space); + this.color = color; + return; + } else { + options = optionsMaybeWithColor; } - let alpha; + + const space = options.space ?? getColorSpace(options); + this.setChannelIds(space); + if (space === 'rgb') this.isRgb = true; + let alpha: number; if (options.alpha === null) { - if (!options.space) { - emitNullAlphaDeprecation(); - } + if (!options.space) emitNullAlphaDeprecation(); alpha = NaN; - this.alphaMissing = true; } else if (options.alpha === undefined) { alpha = 1; } else { @@ -359,9 +456,6 @@ export class SassColor extends Value { switch (space) { case 'rgb': case 'srgb': { - this.channel0Id = 'red'; - this.channel1Id = 'green'; - this.channel2Id = 'blue'; const red = options.red ?? NaN; const green = options.green ?? NaN; const blue = options.blue ?? NaN; @@ -387,9 +481,6 @@ export class SassColor extends Value { case 'a98-rgb': case 'prophoto-rgb': case 'rec2020': - this.channel0Id = 'red'; - this.channel1Id = 'green'; - this.channel2Id = 'blue'; this.color = new Color({ spaceId: encodeSpaceForColorJs(space), coords: [ @@ -402,9 +493,6 @@ export class SassColor extends Value { break; case 'hsl': { - this.channel0Id = 'hue'; - this.channel1Id = 'saturation'; - this.channel2Id = 'lightness'; const hue = normalizeHue(options.hue ?? NaN); const saturation = options.saturation ?? NaN; let lightness = options.lightness ?? NaN; @@ -418,9 +506,6 @@ export class SassColor extends Value { } case 'hwb': { - this.channel0Id = 'hue'; - this.channel1Id = 'whiteness'; - this.channel2Id = 'blackness'; const hue = normalizeHue(options.hue ?? NaN); const whiteness = options.whiteness ?? NaN; const blackness = options.blackness ?? NaN; @@ -434,9 +519,6 @@ export class SassColor extends Value { case 'lab': case 'oklab': { - this.channel0Id = 'lightness'; - this.channel1Id = 'a'; - this.channel2Id = 'b'; let lightness = options.lightness ?? NaN; const a = options.a ?? NaN; const b = options.b ?? NaN; @@ -452,9 +534,6 @@ export class SassColor extends Value { case 'lch': case 'oklch': { - this.channel0Id = 'lightness'; - this.channel1Id = 'chroma'; - this.channel2Id = 'hue'; let lightness = options.lightness ?? NaN; const chroma = options.chroma ?? NaN; const hue = normalizeHue(options.hue ?? NaN); @@ -471,9 +550,6 @@ export class SassColor extends Value { case 'xyz': case 'xyz-d65': case 'xyz-d50': - this.channel0Id = 'x'; - this.channel1Id = 'y'; - this.channel2Id = 'z'; this.color = new Color({ spaceId: encodeSpaceForColorJs(space), coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], @@ -482,8 +558,10 @@ export class SassColor extends Value { break; } - // @TODO ColorJS doesn't seem to allow initial `alpha` to be missing? - if (this.alphaMissing) { + // @TODO Waiting on new release of ColorJS that includes allowing `alpha` + // to be `NaN` on initial construction. + // Fixed in: https://github.com/LeaVerou/color.js/commit/08b39c180565ae61408ad737d91bd71a1f79d3df + if (Number.isNaN(alpha)) { this.color.alpha = NaN; } } @@ -621,21 +699,14 @@ export class SassColor extends Value { return this; } - // Internal helper which mutates the current color object. - _toSpaceInternal(space: KnownColorSpace): void { - this.isRgb = space === 'rgb'; - this.color = this.color.to(encodeSpaceForColorJs(space) as string); - } - /** * Returns a new color that's the result of converting this color to the * specified `space`. */ toSpace(space: KnownColorSpace): SassColor { if (space === this.space) return this; - const color = this.clone(); - color._toSpaceInternal(space); - return color; + const color = this.color.to(encodeSpaceForColorJs(space) as string); + return new SassColor({color, space}); } /** @@ -647,11 +718,6 @@ export class SassColor extends Value { return this.color.inGamut(encodeSpaceForColorJs(space)); } - // Internal helper which mutates the current color object. - _toGamutInternal(space?: KnownColorSpace): void { - this.color.toGamut({space: encodeSpaceForColorJs(space)}); - } - /** * Returns a copy of this color, modified so it is in-gamut for the specified * `space`—or the current color space if `space` is not specified—using the @@ -663,9 +729,10 @@ export class SassColor extends Value { */ toGamut(space?: KnownColorSpace): SassColor { if (this.isInGamut(space)) return this; - const color = this.clone(); - color._toGamutInternal(space); - return color; + const color = this.color + .clone() + .toGamut({space: encodeSpaceForColorJs(space)}); + return new SassColor({color, space: space ?? this.space}); } /** @@ -698,9 +765,7 @@ export class SassColor extends Value { coordId: encodeChannelForColorJs(channel), }); } - if (space === 'rgb') { - val = coordToRgb(val); - } + if (space === 'rgb') val = coordToRgb(val); return NaNtoZero(val); } @@ -763,9 +828,7 @@ export class SassColor extends Value { const channels = color.channels.toArray(); switch (channel) { case color.channel0Id: - if (color.space === 'hsl') { - return fuzzyEquals(channels[1], 0); - } + if (color.space === 'hsl') return fuzzyEquals(channels[1], 0); if (color.space === 'hwb') { return fuzzyEquals(channels[1] + channels[2], 100); } @@ -810,7 +873,7 @@ export class SassColor extends Value { if (weight < 0 || weight > 1) { throw valueError( - `Expected \`weight\` between \`0\` and \`1\`; received \`${weight}\`.` + `Expected \`weight\` between \`0\` and \`1\`, received \`${weight}\`.` ); } @@ -819,8 +882,8 @@ export class SassColor extends Value { const color = this.color.mix(color2.color, 1 - weight, { space: encodeSpaceForColorJs(this.space), hue: hueInterpolationMethod, - } as any); // @TODO Waiting on new ColorJS release to fix type defs - const coords = getCoordsFromColor(color.coords, this.space === 'rgb'); + } as any); // @TODO https://github.com/LeaVerou/color.js/issues/346 + const coords = decodeCoordsFromColorJs(color.coords, this.space === 'rgb'); return new SassColor({ space: this.space, [this.channel0Id]: coords[0], @@ -830,114 +893,51 @@ export class SassColor extends Value { }); } - /** - * Returns a new color that's the result of changing one or more of this - * color's channels. - */ - change( - options: { - [key in ChannelNameHsl]?: number | null; - } & { - space?: ColorSpaceHsl; - } - ): SassColor; - change( - options: { - [key in ChannelNameHwb]?: number | null; - } & { - space?: ColorSpaceHwb; - } - ): SassColor; - change( - options: { - [key in ChannelNameLab]?: number | null; - } & { - space?: ColorSpaceLab; - } - ): SassColor; - change( - options: { - [key in ChannelNameLch]?: number | null; - } & { - space?: ColorSpaceLch; - } - ): SassColor; - change( - options: { - [key in ChannelNameRgb]?: number | null; - } & { - space?: ColorSpaceRgb; - } - ): SassColor; - change( - options: { - [key in ChannelNameXyz]?: number | null; - } & { - space?: ColorSpaceXyz; - } - ): SassColor; - change( - options: { - [key in ChannelName]?: number | null; - } & { - space?: KnownColorSpace; - } - ) { - const spaceSetExplicitly = !!options.space; - let space = options.space ?? this.space; - if (this.isLegacy && !spaceSetExplicitly) { - if ( - propertyIsSet(options.whiteness) || - propertyIsSet(options.blackness) || - (this.space === 'hwb' && propertyIsSet(options.hue)) - ) { - space = 'hwb'; - } else if ( - propertyIsSet(options.hue) || - propertyIsSet(options.saturation) || - propertyIsSet(options.lightness) - ) { - space = 'hsl'; - } else if ( - propertyIsSet(options.red) || - propertyIsSet(options.green) || - propertyIsSet(options.blue) - ) { - space = 'rgb'; - } - if (space !== this.space) { - emitColor4ApiChangeSpaceDeprecation(); - } - } - - // Validate channel values - const keys = Object.keys(options).filter( - key => key !== 'space' - ) as ChannelName[]; - for (const channel of keys) { - validateChannelInSpace(channel, space); - } - if (propertyIsSet(options.alpha) && options.alpha !== null) { - fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); - } - if (propertyIsSet(options.lightness) && options.lightness !== null) { - const maxLightness = space === 'oklab' || space === 'oklch' ? 1 : 100; - assertClamped(options.lightness, 0, maxLightness, 'lightness'); + /** Legacy determination of color space by option channels. */ + private getLegacyChangeSpace(options: ConstructorOptions): KnownColorSpace { + let space: KnownColorSpace | undefined; + if ( + isNumberOrNull(options.whiteness) || + isNumberOrNull(options.blackness) || + (this.space === 'hwb' && isNumberOrNull(options.hue)) + ) { + space = 'hwb'; + } else if ( + isNumberOrNull(options.hue) || + isNumberOrNull(options.saturation) || + isNumberOrNull(options.lightness) + ) { + space = 'hsl'; + } else if ( + isNumberOrNull(options.red) || + isNumberOrNull(options.green) || + isNumberOrNull(options.blue) + ) { + space = 'rgb'; } + if (space !== this.space) emitColor4ApiChangeSpaceDeprecation(); + return space ?? this.space; + } + /** + * Returns a new SassColor in the given `space` that's the result of changing + * one or more of this color's channels. + */ + private getChangedColor( + options: ConstructorOptions, + space: KnownColorSpace, + spaceSetExplicitly: boolean + ): SassColor { const color = this.toSpace(space); const getChangedValue = (channel: ChannelName) => { - if (propertyIsSet(options[channel])) { - return options[channel]; - } + if (isNumberOrNull(options[channel])) return options[channel]; return color.channel(channel); }; - let changedColor: SassColor; switch (space) { case 'hsl': if (spaceSetExplicitly) { - changedColor = new SassColor({ + return new SassColor({ hue: getChangedValue('hue'), saturation: getChangedValue('saturation'), lightness: getChangedValue('lightness'), @@ -946,7 +946,7 @@ export class SassColor extends Value { }); } else { checkChangeDeprecations(options, ['hue', 'saturation', 'lightness']); - changedColor = new SassColor({ + return new SassColor({ hue: options.hue ?? color.channel('hue'), saturation: options.saturation ?? color.channel('saturation'), lightness: options.lightness ?? color.channel('lightness'), @@ -954,11 +954,10 @@ export class SassColor extends Value { space, }); } - break; case 'hwb': if (spaceSetExplicitly) { - changedColor = new SassColor({ + return new SassColor({ hue: getChangedValue('hue'), whiteness: getChangedValue('whiteness'), blackness: getChangedValue('blackness'), @@ -967,7 +966,7 @@ export class SassColor extends Value { }); } else { checkChangeDeprecations(options, ['hue', 'whiteness', 'blackness']); - changedColor = new SassColor({ + return new SassColor({ hue: options.hue ?? color.channel('hue'), whiteness: options.whiteness ?? color.channel('whiteness'), blackness: options.blackness ?? color.channel('blackness'), @@ -975,11 +974,10 @@ export class SassColor extends Value { space, }); } - break; case 'rgb': if (spaceSetExplicitly) { - changedColor = new SassColor({ + return new SassColor({ red: getChangedValue('red'), green: getChangedValue('green'), blue: getChangedValue('blue'), @@ -988,7 +986,7 @@ export class SassColor extends Value { }); } else { checkChangeDeprecations(options, ['red', 'green', 'blue']); - changedColor = new SassColor({ + return new SassColor({ red: options.red ?? color.channel('red'), green: options.green ?? color.channel('green'), blue: options.blue ?? color.channel('blue'), @@ -996,29 +994,26 @@ export class SassColor extends Value { space, }); } - break; case 'lab': case 'oklab': - changedColor = new SassColor({ + return new SassColor({ lightness: getChangedValue('lightness'), a: getChangedValue('a'), b: getChangedValue('b'), alpha: getChangedValue('alpha'), space, }); - break; case 'lch': case 'oklch': - changedColor = new SassColor({ + return new SassColor({ lightness: getChangedValue('lightness'), chroma: getChangedValue('chroma'), hue: getChangedValue('hue'), alpha: getChangedValue('alpha'), space, }); - break; case 'a98-rgb': case 'display-p3': @@ -1026,29 +1021,98 @@ export class SassColor extends Value { case 'rec2020': case 'srgb': case 'srgb-linear': - changedColor = new SassColor({ + return new SassColor({ red: getChangedValue('red'), green: getChangedValue('green'), blue: getChangedValue('blue'), alpha: getChangedValue('alpha'), space, }); - break; case 'xyz': case 'xyz-d50': case 'xyz-d65': - changedColor = new SassColor({ + return new SassColor({ y: getChangedValue('y'), x: getChangedValue('x'), z: getChangedValue('z'), alpha: getChangedValue('alpha'), space, }); - break; } + } - return changedColor.toSpace(this.space); + /** + * Returns a new color that's the result of changing one or more of this + * color's channels. + */ + change( + options: { + [key in ChannelNameHsl]?: number | null; + } & { + space?: ColorSpaceHsl; + } + ): SassColor; + change( + options: { + [key in ChannelNameHwb]?: number | null; + } & { + space?: ColorSpaceHwb; + } + ): SassColor; + change( + options: { + [key in ChannelNameLab]?: number | null; + } & { + space?: ColorSpaceLab; + } + ): SassColor; + change( + options: { + [key in ChannelNameLch]?: number | null; + } & { + space?: ColorSpaceLch; + } + ): SassColor; + change( + options: { + [key in ChannelNameRgb]?: number | null; + } & { + space?: ColorSpaceRgb; + } + ): SassColor; + change( + options: { + [key in ChannelNameXyz]?: number | null; + } & { + space?: ColorSpaceXyz; + } + ): SassColor; + change(options: ConstructorOptions) { + const spaceSetExplicitly = !!options.space; + let space = options.space ?? this.space; + if (this.isLegacy && !spaceSetExplicitly) { + space = this.getLegacyChangeSpace(options); + } + + // Validate channel values + const keys = Object.keys(options).filter( + key => key !== 'space' + ) as ChannelName[]; + for (const channel of keys) { + validateChannelInSpace(channel, space); + } + if (isNumberOrNull(options.alpha) && options.alpha !== null) { + fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); + } + if (isNumberOrNull(options.lightness) && options.lightness !== null) { + const maxLightness = space === 'oklab' || space === 'oklch' ? 1 : 100; + assertClamped(options.lightness, 0, maxLightness, 'lightness'); + } + + return this.getChangedColor(options, space, spaceSetExplicitly).toSpace( + this.space + ); } equals(other: Value): boolean { diff --git a/lib/src/value/utils.ts b/lib/src/value/utils.ts index c1b856fe..18584bfb 100644 --- a/lib/src/value/utils.ts +++ b/lib/src/value/utils.ts @@ -115,7 +115,7 @@ export function fuzzyAssertInRange( throw valueError(`${num} must be between ${min} and ${max}`, name); } -/** Returns `dividend % modulus`, but always in the range `[0, modulus]`. */ +/** Returns `dividend % modulus`, but always in the range `[0, modulus)`. */ export function positiveMod(dividend: number, modulus: number) { const result = dividend % modulus; return result < 0 ? result + modulus : result; From b2036059b6fc0b5087ef2397c77fca8961b6885c Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 15 Nov 2023 17:17:19 -0500 Subject: [PATCH 25/25] update comments --- lib/src/value/color.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 28befc67..b8bcaa36 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -188,9 +188,9 @@ function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { * Normalize discrepancies between Sass channel names and ColorJS channel ids, * converting Sass values to ColorJS values. * - * For some spaces (e.g. Lab and Oklab), ColorJS only accepts `l` and not - * `lightness` as a channel name. This might be a bug: - * https://github.com/LeaVerou/color.js/issues/345. + * @TODO Waiting on a new release of ColorJS that allows Lab spaces to accept + * `lightness` instead of only `l` and not as a channel name. + * Fixed in: https://github.com/LeaVerou/color.js/pull/348 */ function encodeChannelForColorJs(channel: ChannelName): string { if (channel === 'lightness') return 'l'; @@ -882,7 +882,9 @@ export class SassColor extends Value { const color = this.color.mix(color2.color, 1 - weight, { space: encodeSpaceForColorJs(this.space), hue: hueInterpolationMethod, - } as any); // @TODO https://github.com/LeaVerou/color.js/issues/346 + // @TODO Waiting on new release of ColorJS to fix option types. + // Fixed in: https://github.com/LeaVerou/color.js/pull/347 + } as any); const coords = decodeCoordsFromColorJs(color.coords, this.space === 'rgb'); return new SassColor({ space: this.space,