├── .npmrc ├── .gitattributes ├── .gitignore ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── license ├── package.json ├── readme.md ├── index.d.ts ├── test.js └── index.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 22 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-color-palette", 3 | "version": "0.2.1", 4 | "description": "A JavaScript implementation of the Simple Color Palette format — a minimal JSON-based file format for defining color palettes", 5 | "license": "MIT", 6 | "repository": "simple-color-palette/javascript-package", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && tsc index.d.ts" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "color", 31 | "colour", 32 | "palette", 33 | "clr", 34 | "ase", 35 | "swatch", 36 | "json", 37 | "list", 38 | "collection", 39 | "hex", 40 | "css" 41 | ], 42 | "devDependencies": { 43 | "ava": "^6.2.0", 44 | "typescript": "^5.8.3", 45 | "xo": "^0.60.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # simple-color-palette 2 | 3 | > A JavaScript implementation of the [Simple Color Palette](https://simplecolorpalette.com) format — a minimal JSON-based file format for defining color palettes 4 | 5 | *Feedback wanted on the API.* 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install simple-color-palette 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | import ColorPalette from 'simple-color-palette'; 17 | 18 | const redColor = new ColorPalette.Color({ 19 | name: 'Red', 20 | red: 1, 21 | green: 0, 22 | blue: 0, 23 | }); 24 | 25 | const greenColor = new ColorPalette.Color({ 26 | name: 'Green', 27 | red: 0, 28 | green: 1, 29 | blue: 0, 30 | }); 31 | 32 | const palette = new ColorPalette({ 33 | name: 'Traffic Lights', 34 | colors: [ 35 | redColor, 36 | greenColor 37 | ], 38 | }); 39 | 40 | console.log(redColor.components); 41 | // {red: 1, green: 0, blue: 0, opacity: 1} 42 | 43 | // Modify color components 44 | redColor.red = 0.9; 45 | 46 | // Serialize to string 47 | const serialized = palette.serialize(); 48 | 49 | // Load from serialized data 50 | const loadedPalette = ColorPalette.deserialize(serialized); 51 | ``` 52 | 53 | ## API 54 | 55 | See [types](index.d.ts). 56 | 57 | ## Note 58 | 59 | The palette operates in non-linear sRGB, while the serialized version is in linear (gamma-corrected) sRGB for precision. 60 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | A color's components. 3 | */ 4 | export type ColorComponents = { 5 | red: number; 6 | green: number; 7 | blue: number; 8 | opacity: number; 9 | }; 10 | 11 | /** 12 | Options for creating a color. 13 | */ 14 | export type ColorOptions = { 15 | /** 16 | Name for the color. 17 | */ 18 | name?: string; 19 | 20 | /** 21 | Red component. 22 | */ 23 | red: number; 24 | 25 | /** 26 | Green component. 27 | */ 28 | green: number; 29 | 30 | /** 31 | Blue component. 32 | */ 33 | blue: number; 34 | 35 | /** 36 | Opacity value between 0 and 1. 37 | 38 | @default 1 39 | */ 40 | opacity?: number; 41 | 42 | /** 43 | Whether the component values are already in linear sRGB color space. 44 | 45 | If false (default), values are interpreted as non-linear (gamma-corrected) sRGB. 46 | 47 | @default false 48 | */ 49 | isLinear?: boolean; 50 | }; 51 | 52 | /** 53 | Represents a color in extended sRGB (non-linear) color space. 54 | */ 55 | declare class Color { 56 | /** 57 | Creates a color from a hex string. 58 | 59 | Supports the following formats: 60 | - RGB: `'#F00'` → red 61 | - RGBA: `'#F008'` → red with 50% opacity 62 | - RRGGBB: `'#FF0000'` → red 63 | - RRGGBBAA: `'#FF000080'` → red with 50% opacity 64 | 65 | The `#` prefix is optional. Both uppercase and lowercase hex digits are supported. 66 | 67 | @note The input is expected to be in sRGB color space, which is the standard color space for hex colors on the web and in design tools. 68 | 69 | @example 70 | ``` 71 | import ColorPalette from 'simple-color-palette'; 72 | 73 | const red = ColorPalette.Color.fromHexString('#ff0000'); 74 | const green = ColorPalette.Color.fromHexString('00ff00'); 75 | const blue = ColorPalette.Color.fromHexString('#00f'); // Short form 76 | const withHalfOpacity = ColorPalette.Color.fromHexString('#ff000080'); // 50% opacity 77 | ``` 78 | 79 | @note Converting back to hex is not supported since the components can contain values outside the 0-1 range (wide gamut colors) which cannot be represented in the sRGB hex format. 80 | */ 81 | static fromHexString(hex: string): Color; 82 | 83 | /** 84 | Creates a color from a hex number. 85 | 86 | Supports the following formats: 87 | - RGB: `0xF00` → red 88 | - RGBA: `0xF008` → red with 50% opacity 89 | - RRGGBB: `0xFF0000` → red 90 | - RRGGBBAA: `0xFF000080` → red with 50% opacity 91 | 92 | @note The input is expected to be in sRGB color space, which is the standard color space for hex colors on the web and in design tools. 93 | 94 | @example 95 | ``` 96 | import ColorPalette from 'simple-color-palette'; 97 | 98 | const red = ColorPalette.Color.fromHexNumber(0xFF0000); 99 | const green = ColorPalette.Color.fromHexNumber(0x00FF00); 100 | const blue = ColorPalette.Color.fromHexNumber(0x00F); 101 | const withHalfOpacity = ColorPalette.Color.fromHexNumber(0xFF000080); // 50% opacity 102 | ``` 103 | 104 | @note Converting back to hex is not supported since the components can contain values outside the 0-1 range (wide gamut colors) which cannot be represented in the sRGB hex format. 105 | */ 106 | static fromHexNumber(hex: number): Color; 107 | 108 | /** 109 | Name for the color. 110 | */ 111 | name?: string; 112 | 113 | /** 114 | Red component. 115 | 116 | Can be set. 117 | */ 118 | red: number; 119 | 120 | /** 121 | Green component. 122 | 123 | Can be set. 124 | */ 125 | green: number; 126 | 127 | /** 128 | Blue component. 129 | 130 | Can be set. 131 | */ 132 | blue: number; 133 | 134 | /** 135 | Opacity value between 0 and 1. 136 | 137 | Can be set. 138 | */ 139 | opacity: number; 140 | 141 | /** 142 | Color components in extended sRGB (non-linear) color space. 143 | */ 144 | readonly components: ColorComponents; 145 | 146 | /** 147 | Color components in extended sRGB (linear) color space. 148 | */ 149 | readonly linearComponents: ColorComponents; 150 | 151 | /** 152 | Creates a new color. 153 | 154 | Values are interpreted as extended sRGB (non-linear) by default. 155 | 156 | @note Values are rounded to 4 decimal places. Opacity is clamped to 0...1. 157 | */ 158 | constructor(options: ColorOptions); 159 | } 160 | 161 | /** 162 | A collection of colors. 163 | 164 | @example 165 | ``` 166 | import ColorPalette from 'simple-color-palette'; 167 | 168 | const redColor = new ColorPalette.Color({ 169 | name: 'Red', 170 | red: 1, 171 | green: 0, 172 | blue: 0, 173 | }); 174 | 175 | const greenColor = new ColorPalette.Color({ 176 | name: 'Green', 177 | red: 0, 178 | green: 1, 179 | blue: 0, 180 | }); 181 | 182 | const palette = new ColorPalette({ 183 | name: 'Traffic Lights', 184 | colors: [ 185 | redColor, 186 | greenColor 187 | ], 188 | }); 189 | 190 | console.log(redColor.components); 191 | // {red: 1, green: 0, blue: 0, opacity: 1} 192 | 193 | // Modify color components 194 | redColor.red = 0.9; 195 | 196 | // Serialize to string 197 | const serialized = palette.serialize(); 198 | 199 | // Load from serialized data 200 | const loadedPalette = ColorPalette.deserialize(serialized); 201 | ``` 202 | */ 203 | export default class ColorPalette { 204 | // eslint-disable-next-line @typescript-eslint/naming-convention 205 | static Color: typeof Color; 206 | 207 | /** 208 | Creates a new color palette from serialized data. 209 | */ 210 | static deserialize(data: string): ColorPalette; 211 | 212 | /** 213 | Name for the palette. 214 | */ 215 | name?: string; 216 | 217 | /** 218 | Colors in the palette. 219 | */ 220 | colors: Color[]; 221 | 222 | /** 223 | Creates a new color palette. 224 | */ 225 | constructor(options?: { 226 | /** 227 | Name for the palette. 228 | */ 229 | name?: string; 230 | 231 | /** 232 | Array of colors in the palette. 233 | */ 234 | colors?: Color[]; 235 | }); 236 | 237 | /** 238 | Serializes the palette to a string. 239 | */ 240 | serialize(): string; 241 | } 242 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import ColorPalette from './index.js'; 3 | 4 | // --- Helpers --- 5 | 6 | function round(value, decimals = 2) { 7 | return Math.round(value * (10 ** decimals)) / (10 ** decimals); 8 | } 9 | 10 | function assertColorComponents(t, color, expected, decimals = 2) { 11 | t.is(round(color.red, decimals), expected.red); 12 | t.is(round(color.green, decimals), expected.green); 13 | t.is(round(color.blue, decimals), expected.blue); 14 | if (expected.opacity !== undefined) { 15 | t.is(round(color.opacity, decimals), expected.opacity); 16 | } 17 | } 18 | 19 | const {Color} = ColorPalette; 20 | 21 | // --- Tests --- 22 | 23 | test('color creation and component access', t => { 24 | const color = new Color({ 25 | red: 0.5, 26 | green: 0.7, 27 | blue: 0.3, 28 | opacity: 0.8, 29 | name: 'Test', 30 | }); 31 | 32 | t.is(color.name, 'Test'); 33 | t.is(color.opacity, 0.8); 34 | assertColorComponents(t, color, {red: 0.5, green: 0.7, blue: 0.3}); 35 | }); 36 | 37 | test('color creation with linear values', t => { 38 | const color = new Color({ 39 | red: 0.5, 40 | green: 0.7, 41 | blue: 0.3, 42 | isLinear: true, 43 | }); 44 | const linear = color.linearComponents; 45 | assertColorComponents(t, linear, {red: 0.5, green: 0.7, blue: 0.3}); 46 | }); 47 | 48 | test('color component validation', t => { 49 | t.throws(() => { 50 | // eslint-disable-next-line no-new 51 | new Color({red: 'invalid', green: 0, blue: 0}); 52 | }, {message: /must be a number/}); 53 | }); 54 | 55 | test('color opacity handling', t => { 56 | const color = new Color({red: 0, green: 0, blue: 0}); 57 | 58 | color.opacity = 1.5; 59 | t.is(color.opacity, 1); 60 | 61 | color.opacity = -0.5; 62 | t.is(color.opacity, 0); 63 | 64 | t.throws(() => { 65 | color.opacity = 'invalid'; 66 | }, {message: /must be a number/}); 67 | }); 68 | 69 | test('palette creation and validation', t => { 70 | const red = new Color({red: 1, green: 0, blue: 0}); 71 | const green = new Color({red: 0, green: 1, blue: 0}); 72 | const palette = new ColorPalette({colors: [red, green], name: 'Test'}); 73 | t.is(palette.colors.length, 2); 74 | t.is(palette.name, 'Test'); 75 | 76 | t.throws(() => { 77 | // eslint-disable-next-line no-new 78 | new ColorPalette({colors: [{}]}); 79 | }, {message: /must be an instance of Color/}); 80 | }); 81 | 82 | test('serialization roundtrip', t => { 83 | const original = new ColorPalette({ 84 | colors: [ 85 | new Color({ 86 | red: 1, green: 0, blue: 0, name: 'Red', 87 | }), 88 | new Color({ 89 | red: 0, green: 1, blue: 0, opacity: 0.5, name: 'Green', 90 | }), 91 | ], 92 | name: 'Test', 93 | }); 94 | const serialized = original.serialize(); 95 | const deserialized = ColorPalette.deserialize(serialized); 96 | 97 | t.is(deserialized.name, original.name); 98 | t.is(deserialized.colors.length, original.colors.length); 99 | 100 | for (let i = 0; i < original.colors.length; i++) { 101 | const originalColor = original.colors[i]; 102 | const deserializedColor = deserialized.colors[i]; 103 | t.is(deserializedColor.name, originalColor.name); 104 | t.is(deserializedColor.opacity, originalColor.opacity); 105 | assertColorComponents( 106 | t, 107 | deserializedColor.linearComponents, 108 | originalColor.linearComponents, 109 | 5, 110 | ); 111 | } 112 | }); 113 | 114 | test('deserialization validation', t => { 115 | t.throws(() => { 116 | ColorPalette.deserialize('/'); 117 | }, {message: /not valid JSON/}); 118 | 119 | t.throws(() => { 120 | ColorPalette.deserialize('{}'); 121 | }, {message: 'Colors must be an array'}); 122 | 123 | t.throws(() => { 124 | ColorPalette.deserialize(JSON.stringify({ 125 | colors: [{components: 'invalid'}], 126 | })); 127 | }, {message: 'Components must be an array'}); 128 | 129 | t.throws(() => { 130 | ColorPalette.deserialize(JSON.stringify({ 131 | colors: [{components: [1, 2]}], 132 | })); 133 | }, {message: 'Components must have 3 or 4 values'}); 134 | 135 | t.throws(() => { 136 | ColorPalette.deserialize(JSON.stringify({ 137 | colors: [{components: [-1, 0, 0]}], 138 | })); 139 | }, {message: 'Component values must be numbers'}); 140 | }); 141 | 142 | test('color component modification', t => { 143 | const color = new Color({red: 0.5, green: 0.5, blue: 0.5}); 144 | color.red = 1; 145 | color.green = 0; 146 | color.blue = 0; 147 | assertColorComponents(t, color, {red: 1, green: 0, blue: 0}); 148 | 149 | t.throws(() => { 150 | color.green = 'invalid'; 151 | }, {message: /number/}); 152 | }); 153 | 154 | test('precision rounding', t => { 155 | const color = new Color({ 156 | red: 0.123_456, 157 | green: 0.123_45, 158 | blue: 0.123_444, 159 | opacity: 0.123_449, 160 | isLinear: true, 161 | }); 162 | const linear = color.linearComponents; 163 | t.is(linear.red, 0.123_46); 164 | t.is(linear.green, 0.123_45); 165 | t.is(linear.blue, 0.123_44); 166 | t.is(linear.opacity, 0.123_45); 167 | }); 168 | 169 | test('precision roundtrip', t => { 170 | const color = new Color({ 171 | red: 0.123_456, 172 | green: 0.123_45, 173 | blue: 0.123_444, 174 | opacity: 0.123_449, 175 | isLinear: true, 176 | }); 177 | 178 | // eslint-disable-next-line unicorn/prefer-structured-clone 179 | const parsed = JSON.parse(JSON.stringify(color)); 180 | 181 | t.deepEqual(parsed.components, [0.123_46, 0.123_45, 0.123_44, 0.123_45]); 182 | }); 183 | 184 | // --- Parameterized hex string and number tests --- 185 | 186 | const hexStringCases = [ 187 | {hexString: '#FF0000', expected: {red: 1, green: 0, blue: 0}}, 188 | {hexString: 'F00', expected: {red: 1, green: 0, blue: 0}}, 189 | { 190 | hexString: '#FF000080', expected: { 191 | red: 1, green: 0, blue: 0, opacity: 0.5, 192 | }, 193 | }, 194 | { 195 | hexString: 'F008', expected: { 196 | red: 1, green: 0, blue: 0, opacity: 0.53, 197 | }, 198 | }, 199 | ]; 200 | 201 | for (const {hexString, expected} of hexStringCases) { 202 | test(`hex string initialization: ${hexString}`, t => { 203 | const color = Color.fromHexString(hexString); 204 | assertColorComponents(t, color, expected); 205 | }); 206 | } 207 | 208 | test('hex string initialization: invalid formats', t => { 209 | const invalidHexStrings = ['', '#', '#F', '#FF', '#FFFFF', '#FFFFFFF', '#GG0000']; 210 | for (const invalidHexString of invalidHexStrings) { 211 | t.throws(() => { 212 | Color.fromHexString(invalidHexString); 213 | }, {message: /Invalid hex color format/}); 214 | } 215 | }); 216 | 217 | const hexNumberCases = [ 218 | {hexNumber: 0xFF_00_00, expected: {red: 1, green: 0, blue: 0}}, 219 | {hexNumber: 0xF_00, expected: {red: 1, green: 0, blue: 0}}, 220 | {hexNumber: 0x12_34_56, expected: {red: 0.0706, green: 0.2039, blue: 0.3373}, decimals: 4}, 221 | { 222 | hexNumber: 0xFF_00_00_80, expected: { 223 | red: 1, green: 0, blue: 0, opacity: 0.502, 224 | }, decimals: 4, 225 | }, 226 | ]; 227 | 228 | for (const {hexNumber, expected, decimals = 2} of hexNumberCases) { 229 | test(`hex number initialization: ${hexNumber.toString(16)}`, t => { 230 | const color = Color.fromHexNumber(hexNumber); 231 | assertColorComponents(t, color, expected, decimals); 232 | }); 233 | } 234 | 235 | test('hex number initialization: invalid values', t => { 236 | t.throws(() => { 237 | Color.fromHexNumber(-1); 238 | }, {message: /Invalid hex value/}); 239 | 240 | t.throws(() => { 241 | Color.fromHexNumber(0x1_00_00_00_00); 242 | }, {message: /Invalid hex value/}); 243 | }); 244 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const roundToFiveDecimals = number => { 2 | const multiplier = 100_000; 3 | return Math.round(number * multiplier) / multiplier; 4 | }; 5 | 6 | const sRGBToLinear = srgb => { 7 | if (srgb <= 0.040_45) { 8 | return srgb / 12.92; 9 | } 10 | 11 | return ((srgb + 0.055) / 1.055) ** 2.4; 12 | }; 13 | 14 | const linearToSRGB = linear => { 15 | if (linear <= 0.003_130_8) { 16 | return linear * 12.92; 17 | } 18 | 19 | return (((linear ** (1 / 2.4)) * 1.055) - 0.055); 20 | }; 21 | 22 | const clampOpacity = value => Math.max(0, Math.min(1, value)); 23 | 24 | const validateColorComponent = color => { 25 | if (!Array.isArray(color.components)) { 26 | throw new TypeError('Components must be an array'); 27 | } 28 | 29 | if (color.components.length !== 3 && color.components.length !== 4) { 30 | throw new Error('Components must have 3 or 4 values'); 31 | } 32 | 33 | for (const component of color.components) { 34 | if (typeof component !== 'number' || component < 0) { 35 | throw new Error('Component values must be numbers'); 36 | } 37 | } 38 | }; 39 | 40 | const validatePalette = palette => { 41 | if (!Array.isArray(palette.colors)) { 42 | throw new TypeError('Colors must be an array'); 43 | } 44 | 45 | for (const color of palette.colors) { 46 | validateColorComponent(color); 47 | } 48 | }; 49 | 50 | const validateComponent = (value, name) => { 51 | if (typeof value !== 'number') { 52 | throw new TypeError(`${name} component must be a number`); 53 | } 54 | }; 55 | 56 | class Color { 57 | name; 58 | #linearRed; 59 | #linearGreen; 60 | #linearBlue; 61 | #opacity; 62 | 63 | constructor( 64 | { 65 | name, 66 | red, 67 | green, 68 | blue, 69 | opacity = 1, 70 | isLinear = false, 71 | } = {}, 72 | ) { 73 | validateComponent(red, 'Red'); 74 | validateComponent(green, 'Green'); 75 | validateComponent(blue, 'Blue'); 76 | validateComponent(opacity, 'Opacity'); 77 | 78 | this.name = name; 79 | this.#linearRed = roundToFiveDecimals(isLinear ? red : sRGBToLinear(red)); 80 | this.#linearGreen = roundToFiveDecimals(isLinear ? green : sRGBToLinear(green)); 81 | this.#linearBlue = roundToFiveDecimals(isLinear ? blue : sRGBToLinear(blue)); 82 | this.#opacity = roundToFiveDecimals(clampOpacity(opacity)); 83 | } 84 | 85 | static fromHexString(hex) { 86 | const string = hex.trim(); 87 | const withoutHash = string.startsWith('#') ? string.slice(1) : string; 88 | 89 | // Convert 3/4 character hex to 6/8 character hex 90 | const expanded = (withoutHash.length === 3 || withoutHash.length === 4) 91 | ? [...withoutHash].map(x => x + x).join('') 92 | : withoutHash; 93 | 94 | if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(expanded)) { 95 | throw new Error('Invalid hex color format'); 96 | } 97 | 98 | const value = Number.parseInt(expanded, 16); 99 | return this.fromHexNumber(value); 100 | } 101 | 102 | static fromHexNumber(hex) { 103 | if (!Number.isInteger(hex) || hex < 0) { 104 | throw new Error('Invalid hex value'); 105 | } 106 | 107 | let red; 108 | let green; 109 | let blue; 110 | let opacity = 1; 111 | 112 | /* eslint-disable no-bitwise */ 113 | if (hex <= 0xF_FF) { // 12-bit RGB 114 | red = ((hex >> 8) & 0xF) / 15; 115 | green = ((hex >> 4) & 0xF) / 15; 116 | blue = (hex & 0xF) / 15; 117 | } else if (hex <= 0xFF_FF) { // 16-bit RGBA 118 | red = ((hex >> 12) & 0xF) / 15; 119 | green = ((hex >> 8) & 0xF) / 15; 120 | blue = ((hex >> 4) & 0xF) / 15; 121 | opacity = (hex & 0xF) / 15; 122 | } else if (hex <= 0xFF_FF_FF) { // 24-bit RGB 123 | red = ((hex >> 16) & 0xFF) / 255; 124 | green = ((hex >> 8) & 0xFF) / 255; 125 | blue = (hex & 0xFF) / 255; 126 | } else if (hex <= 0xFF_FF_FF_FF) { // 32-bit RGBA 127 | red = ((hex >> 24) & 0xFF) / 255; 128 | green = ((hex >> 16) & 0xFF) / 255; 129 | blue = ((hex >> 8) & 0xFF) / 255; 130 | opacity = (hex & 0xFF) / 255; 131 | } else { 132 | throw new Error('Invalid hex value'); 133 | } 134 | /* eslint-enable no-bitwise */ 135 | 136 | return new Color({ 137 | red, green, blue, opacity, 138 | }); 139 | } 140 | 141 | get red() { 142 | return linearToSRGB(this.#linearRed); 143 | } 144 | 145 | // We don't round in setters to maintain precision during calculations (like `red += 0.1`). 146 | // Rounding only happens during initialization and serialization. 147 | set red(value) { 148 | if (typeof value !== 'number') { 149 | throw new TypeError('Red component must be a number'); 150 | } 151 | 152 | this.#linearRed = sRGBToLinear(value); 153 | } 154 | 155 | get green() { 156 | return linearToSRGB(this.#linearGreen); 157 | } 158 | 159 | set green(value) { 160 | if (typeof value !== 'number') { 161 | throw new TypeError('Green component must be a number'); 162 | } 163 | 164 | this.#linearGreen = sRGBToLinear(value); 165 | } 166 | 167 | get blue() { 168 | return linearToSRGB(this.#linearBlue); 169 | } 170 | 171 | set blue(value) { 172 | if (typeof value !== 'number') { 173 | throw new TypeError('Blue component must be a number'); 174 | } 175 | 176 | this.#linearBlue = sRGBToLinear(value); 177 | } 178 | 179 | get opacity() { 180 | return this.#opacity; 181 | } 182 | 183 | set opacity(value) { 184 | if (typeof value !== 'number') { 185 | throw new TypeError('Opacity must be a number'); 186 | } 187 | 188 | this.#opacity = clampOpacity(value); 189 | } 190 | 191 | get components() { 192 | return { 193 | red: this.red, 194 | green: this.green, 195 | blue: this.blue, 196 | opacity: this.opacity, 197 | }; 198 | } 199 | 200 | get linearComponents() { 201 | return { 202 | red: this.#linearRed, 203 | green: this.#linearGreen, 204 | blue: this.#linearBlue, 205 | opacity: this.#opacity, 206 | }; 207 | } 208 | 209 | toJSON() { 210 | const components = [ 211 | roundToFiveDecimals(this.#linearRed), 212 | roundToFiveDecimals(this.#linearGreen), 213 | roundToFiveDecimals(this.#linearBlue), 214 | ]; 215 | 216 | if (this.#opacity !== 1) { 217 | components.push(roundToFiveDecimals(this.#opacity)); 218 | } 219 | 220 | const result = {components}; 221 | if (this.name) { 222 | result.name = this.name; 223 | } 224 | 225 | return result; 226 | } 227 | } 228 | 229 | export default class ColorPalette { 230 | name; 231 | colors; 232 | 233 | constructor( 234 | { 235 | colors = [], 236 | name, 237 | } = {}, 238 | ) { 239 | if (!Array.isArray(colors)) { 240 | throw new TypeError('The `colors` must be an array'); 241 | } 242 | 243 | for (const color of colors) { 244 | if (!(color instanceof Color)) { 245 | throw new TypeError('Each color must be an instance of Color'); 246 | } 247 | } 248 | 249 | this.name = name; 250 | this.colors = [...colors]; 251 | } 252 | 253 | static deserialize(data) { 254 | const parsed = JSON.parse(data); 255 | validatePalette(parsed); 256 | 257 | const colors = parsed.colors.map(color => { 258 | const [red, green, blue, opacity = 1] = color.components; 259 | 260 | return new Color({ 261 | name: color.name, 262 | red: roundToFiveDecimals(red), 263 | green: roundToFiveDecimals(green), 264 | blue: roundToFiveDecimals(blue), 265 | opacity: roundToFiveDecimals(opacity), 266 | isLinear: true, 267 | }); 268 | }); 269 | 270 | return new ColorPalette({colors, name: parsed.name}); 271 | } 272 | 273 | serialize() { 274 | return JSON.stringify(this, undefined, '\t'); 275 | } 276 | 277 | toJSON() { 278 | return { 279 | ...(this.name && {name: this.name}), 280 | colors: this.colors, 281 | }; 282 | } 283 | } 284 | 285 | ColorPalette.Color = Color; 286 | --------------------------------------------------------------------------------