├── .github ├── funding.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── benchmarks.js ├── examples ├── bootstrap.png ├── css-tricks.png ├── foundation.png ├── project-wallace.png └── smashing-magazine.png ├── index.js ├── license ├── package-lock.json ├── package.json ├── readme.md ├── test.js ├── tsconfig.json └── vite.config.js /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [] 2 | patreon: bartveneman 3 | open_collective: projectwallace 4 | custom: ['https://www.projectwallace.com/sponsor', 'https://www.paypal.me/bartveneman'] 5 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: NPM Publish 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | cache: "npm" 18 | - run: npm install --ignore-scripts --no-audit --no-fund 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | cache: "npm" 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm install --ignore-scripts --no-audit --no-fund 31 | - run: npm run build 32 | - run: npm publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | unit: 14 | name: Unit tests 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | cache: "npm" 22 | - run: npm install --no-audit --no-fund --ignore-scripts 23 | - run: npm test 24 | lint: 25 | name: Lint 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Use Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | cache: "npm" 33 | - run: npm install --no-audit --no-fund --ignore-scripts 34 | - name: Check types 35 | run: npm run check 36 | - name: Lint JS 37 | run: npx oxlint@latest -D perf 38 | - name: Build package 39 | run: npm run build 40 | env: 41 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 42 | - name: "Publint" 43 | run: npx publint 44 | - name: Run tests 45 | run: npx c8 --reporter=lcov npm test 46 | - name: Upload coverage reports to Codecov 47 | uses: codecov/codecov-action@v4.0.1 48 | with: 49 | token: ${{ secrets.CODECOV_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | node_modules 3 | .vscode 4 | dist 5 | codecov 6 | coverage -------------------------------------------------------------------------------- /benchmarks.js: -------------------------------------------------------------------------------- 1 | import { Bench } from "tinybench" 2 | import { withCodSpeed } from "@codspeed/tinybench-plugin" 3 | import { convert, sort } from './index.js' 4 | 5 | let bench = withCodSpeed(new Bench()) 6 | 7 | bench.add('real world sort example #1', () => { 8 | sort([ 9 | '#4b4747', 10 | '#d70c0b', 11 | '#f00', 12 | '#f22b24', 13 | '#ff6930', 14 | '#eb6c1e', 15 | '#eb6d1e', 16 | '#f57917', 17 | '#ff8a0a', 18 | '#f7a336', 19 | '#feb95a', 20 | '#eca920', 21 | '#f1c15d', 22 | '#f1c260', 23 | '#ff0', 24 | '#c8d05b', 25 | '#ccd557', 26 | '#d2ff52', 27 | '#10ac47', 28 | '#04a03b', 29 | '#03fff3', 30 | '#25bbc3', 31 | '#38d7df', 32 | '#15b8ec', 33 | '#00adea', 34 | '#8e34c9', 35 | '#9a3dd1', 36 | '#cd66f6', 37 | '#fff', 38 | 'rgba(255,255,255,0.2)', 39 | 'rgba(255,255,255,0.07)', 40 | '#f9f9f9', 41 | '#f4f4f4', 42 | '#f2f2f2', 43 | '#e4e4e4', 44 | '#ddd', 45 | '#c0c0c0', 46 | '#666', 47 | '#4a4a4a', 48 | '#1d1d1d', 49 | '#0d0d0d', 50 | '#000', 51 | 'rgba(0,0,0,0.8)', 52 | 'rgba(0,0,0,0.6)', 53 | 'rgba(0,0,0,0.4)', 54 | 'rgba(0,0,0,0.1)', 55 | 'rgba(0,0,0,0.05)' 56 | ]) 57 | }) 58 | 59 | bench.add('real world sort example #2 (nerdy.dev)', () => { 60 | sort([ 61 | "rgb(66, 99, 235)", 62 | "rgb(174, 62, 201)", 63 | "rgb(3, 5, 7)", 64 | "rgb(73, 80, 87)", 65 | "rgb(248, 249, 250)", 66 | "rgb(233, 236, 239)", 67 | "rgb(222, 226, 230)", 68 | "rgb(206, 212, 218)", 69 | "rgb(250, 82, 82)", 70 | "rgb(255, 168, 168)", 71 | "rgb(134, 142, 150)", 72 | "rgb(145, 167, 255)", 73 | "rgb(229, 153, 247)", 74 | "rgb(241, 243, 245)", 75 | "rgb(33, 37, 41)", 76 | "rgb(52, 58, 64)", 77 | "rgb(186, 200, 255)", 78 | "rgb(238, 190, 250)", 79 | "rgb(201, 42, 42)", 80 | "rgb(255, 201, 201)", 81 | "rgb(43, 138, 62)", 82 | "rgb(211, 249, 216)", 83 | "rgb(51, 154, 240)", 84 | "rgb(22, 25, 29)", 85 | "rgb(173, 181, 189)", 86 | "rgb(13, 15, 18)", 87 | "rgb(190, 75, 219)", 88 | "rgb(130, 201, 30)", 89 | "rgb(253, 126, 20)", 90 | "rgb(255, 224, 102)", 91 | "rgb(102, 217, 232)", 92 | "rgb(237, 242, 255)", 93 | "rgb(59, 91, 219)", 94 | "rgb(219, 228, 255)", 95 | "transparent", 96 | "color(display-p3 0.1 0.4 1)", 97 | "color(display-p3 0.6 0.2 1)", 98 | "rgb(255, 255, 255)", 99 | "rgb(23, 26, 28)", 100 | "rgba(0, 0, 0, 0.067)", 101 | "rgba(255, 255, 255, 0.067)", 102 | "rgba(0, 0, 0, 0.467)", 103 | "currentcolor", 104 | "rgb(102, 51, 153)", 105 | "cyan", 106 | "rgb(255, 20, 147)", 107 | "rgb(148, 97, 253)", 108 | "rgb(45, 217, 254)", 109 | "color(display-p3 1 0 0)", 110 | "color(display-p3 0 0.75 1)", 111 | "color(display-p3 1 0 1)", 112 | "color(display-p3 0.5 0 1)", 113 | "color(display-p3 0.5 0.35 1)", 114 | "color(display-p3 0 0 1)", 115 | "color(display-p3 0 1 0)", 116 | "color(display-p3 1 0.5 0)", 117 | "color(display-p3 1 1 0)", 118 | "rgba(0, 0, 0, 0)", 119 | "rgb(137, 41, 255)", 120 | "rgb(230, 98, 230)", 121 | "color(display-p3 0.001 0.015 0.03)" 122 | ]) 123 | }) 124 | 125 | bench.add('convert hex', () => convert('#f00')) 126 | bench.add('convert hex with alpha', () => convert('#f000')) 127 | bench.add('convert rgba()', () => convert('rgba(255,0,0,0.5)')) 128 | bench.add('convert rgba() (modern syntax)', () => convert('rgba(255 0 0 / 0.5)')) 129 | bench.add('convert rgb()', () => convert('rgb(255,0,0)')) 130 | bench.add('convert rgb() (modern syntax)', () => convert('rgb(255 0 0)')) 131 | bench.add('convert hsl()', () => convert('hsl(0,100%,50%)')) 132 | bench.add('convert hsl() (modern syntax)', () => convert('hsl(0 100% 50%)')) 133 | bench.add('convert hsla()', () => convert('hsla(0,100%,50%,0.5)')) 134 | bench.add('convert hsla() (modern syntax)', () => convert('hsla(0 100% 50% / 0.5)')) 135 | bench.add('convert lch()', () => convert('lch(52.2345% 72.2 56.2 / .5)')) 136 | bench.add('convert oklch()', () => convert('oklch(25% .148 81.72)')) 137 | bench.add('convert color()', () => convert('color(display-p3 1 0.5 0 / .5)')) 138 | bench.add('convert relative color()', () => convert('color(from green srgb r g b / 0.5)')) 139 | bench.add('convert named color', () => convert('red')) 140 | bench.add('convert transparent', () => convert('transparent')) 141 | bench.add('convert invalid color', () => convert('invalid')) 142 | bench.add('convert system color', () => convert('Highlight')) 143 | 144 | await bench.warmup() 145 | await bench.run() 146 | 147 | console.table(bench.table()) 148 | -------------------------------------------------------------------------------- /examples/bootstrap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectwallace/color-sorter/45d7f39802d89888235d9a83c8ed4cdaf273717b/examples/bootstrap.png -------------------------------------------------------------------------------- /examples/css-tricks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectwallace/color-sorter/45d7f39802d89888235d9a83c8ed4cdaf273717b/examples/css-tricks.png -------------------------------------------------------------------------------- /examples/foundation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectwallace/color-sorter/45d7f39802d89888235d9a83c8ed4cdaf273717b/examples/foundation.png -------------------------------------------------------------------------------- /examples/project-wallace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectwallace/color-sorter/45d7f39802d89888235d9a83c8ed4cdaf273717b/examples/project-wallace.png -------------------------------------------------------------------------------- /examples/smashing-magazine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectwallace/color-sorter/45d7f39802d89888235d9a83c8ed4cdaf273717b/examples/smashing-magazine.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { 2 | parse, 3 | to as convertColor, 4 | ColorSpace, 5 | sRGB, 6 | P3, 7 | LCH, 8 | HSL, 9 | OKLCH, 10 | } from "colorjs.io/fn" 11 | 12 | // Register color spaces for parsing and converting 13 | ColorSpace.register(sRGB) // Parses keywords and hex colors 14 | ColorSpace.register(P3) 15 | ColorSpace.register(HSL) 16 | ColorSpace.register(LCH) 17 | ColorSpace.register(OKLCH) 18 | 19 | /** 20 | * @typedef NormalizedColor 21 | * @property {number} hue 22 | * @property {number} saturation 23 | * @property {number} lightness 24 | * @property {number} alpha 25 | */ 26 | 27 | /** 28 | * @typedef {NormalizedColor & { authored: string }} NormalizedColorWithAuthored 29 | */ 30 | 31 | /** 32 | * @param {string | number | {raw: string} | undefined | null} value 33 | * @returns {number} 34 | * @todo Make this faster based on usage heuristics 35 | */ 36 | function numerify(value) { 37 | if (typeof value === 'number' && Number.isFinite(value)) { 38 | return value 39 | } 40 | return 0 41 | } 42 | 43 | /** 44 | * Convert a CSS (string) color into a normalized object that can be used for comparison 45 | * @param {string} authored 46 | * @returns {NormalizedColorWithAuthored} 47 | * @example convert('red') 48 | */ 49 | export function convert(authored) { 50 | try { 51 | let parsed = parse(authored) 52 | let converted = parsed.spaceId === 'hsl' ? parsed : convertColor(parsed, HSL) 53 | let hsl = converted.coords 54 | let hue = numerify(hsl[0]) 55 | let saturation = numerify(hsl[1]) 56 | let lightness = numerify(hsl[2]) 57 | let alpha = numerify(converted.alpha) 58 | 59 | return { 60 | hue, 61 | saturation, 62 | lightness, 63 | alpha, 64 | authored 65 | } 66 | } catch (error) { 67 | return { 68 | hue: 0, 69 | saturation: 0, 70 | lightness: 0, 71 | alpha: 0, 72 | authored 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * @param {NormalizedColorWithAuthored} a 79 | * @param {NormalizedColorWithAuthored} b 80 | * @returns {number} 81 | */ 82 | export function compare(a, b) { 83 | // Move grey-ish values to the back 84 | if ( 85 | (a.saturation === 0 || b.saturation === 0) && 86 | a.saturation !== b.saturation 87 | ) { 88 | return b.saturation - a.saturation 89 | } 90 | 91 | // Sort by hue (lowest first) 92 | if (a.hue !== b.hue) { 93 | return a.hue - b.hue 94 | } 95 | 96 | // Sort by saturation (highest first) 97 | if (a.saturation !== b.saturation) { 98 | return a.saturation - b.saturation 99 | } 100 | 101 | // Comparing gray values, light before dark 102 | if (a.saturation === 0 && b.saturation === 0) { 103 | if (a.lightness !== b.lightness) { 104 | return b.lightness - a.lightness 105 | } 106 | } 107 | 108 | // Sort by transparency, least transparent first 109 | if (a.alpha === b.alpha) { 110 | return a.authored.toLowerCase().localeCompare(b.authored.toLowerCase()) 111 | } 112 | 113 | return b.alpha - a.alpha 114 | } 115 | 116 | /** 117 | * Function that sorts colors 118 | * @param {string} a 119 | * @param {string} b 120 | * @returns {number} 121 | * @example ['red', 'yellow'].sort(sortFn) 122 | */ 123 | export function sortFn(a, b) { 124 | let colorA = convert(a) 125 | let colorB = convert(b) 126 | 127 | return compare(colorA, colorB) 128 | } 129 | 130 | /** 131 | * Sort the `colors` array using `Array.sort()`, so beware that it changes the source input 132 | * @param {string[]} colors 133 | * @returns {string[]} sorted 134 | * @example sort(['red', 'yellow']) 135 | */ 136 | export function sort(colors) { 137 | return colors.sort(sortFn) 138 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Bart Veneman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "color-sorter", 3 | "version": "7.0.0", 4 | "description": "Sort colors in a visually pleasing way.", 5 | "homepage": "https://github.com/projectwallace/color-sorter", 6 | "repository": "projectwallace/color-sorter", 7 | "engines": { 8 | "node": ">=18.0.0" 9 | }, 10 | "type": "module", 11 | "exports": { 12 | "types": "./dist/index.d.ts", 13 | "default": "./dist/color-sorter.js" 14 | }, 15 | "types": "./dist/index.d.ts", 16 | "main": "./dist/color-sorter.js", 17 | "scripts": { 18 | "test": "uvu", 19 | "build": "vite build", 20 | "check": "tsc --noEmit" 21 | }, 22 | "files": [ 23 | "dist" 24 | ], 25 | "keywords": [ 26 | "css", 27 | "color-sorting", 28 | "color-sort", 29 | "colors", 30 | "statistics", 31 | "stats", 32 | "analytics", 33 | "sort", 34 | "order" 35 | ], 36 | "author": "Bart Veneman", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/projectwallace/color-sorter/issues" 40 | }, 41 | "dependencies": { 42 | "colorjs.io": "^0.6.0-alpha.1" 43 | }, 44 | "devDependencies": { 45 | "@codecov/vite-plugin": "^1.7.0", 46 | "c8": "^10.1.3", 47 | "uvu": "^0.5.6", 48 | "vite": "^6.0.7", 49 | "vite-plugin-dts": "^4.4.0" 50 | } 51 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # color-sorter 2 | 3 | ![Color sorter](https://repository-images.githubusercontent.com/142018423/f0333800-be49-11ea-8033-0e3df5daf1ab) 4 | 5 | Sort CSS colors by hue, then by saturation. Black-grey-white colors (colors with 6 | 0% saturation) are shifted to the end. Fully transparent colors are placed at 7 | the _very_ end. 8 | 9 | This sorting algorithm is very opinionated and might not fit _your_ needs! 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { sortFn, sort } from "color-sorter"; 15 | var colors = ["#000", "red", "hsl(0, 10%, 60%)"]; 16 | var sorted = colors.sort(sortFn); 17 | // Or: 18 | // sorted = sort(colors) 19 | 20 | // => sorted: 21 | // [ 22 | // 'red', 23 | // 'hsl(0, 10%, 60%)', 24 | // '#000' 25 | // ] 26 | ``` 27 | 28 | Want to try it out? 29 | 30 | - [Stackblitz example with ESM](https://stackblitz.com/edit/color-sorter-example-esm?file=index.js&view=editor) 31 | - [Stackblitz example with CommonJS](https://stackblitz.com/edit/color-sorter-example-cjs?file=index.js&view=editor) 32 | 33 | ## Examples 34 | 35 | These examples can be seen on [Project Wallace](https://projectwallace.com) 36 | where this package is used for sorting the colors. 37 | 38 | ### CSS-Tricks 39 | 40 | ![CSS Tricks color sort example](/examples/css-tricks.png) 41 | 42 | ### Smashing Magazine 43 | 44 | ![Smashing Magazine color sort example](/examples/smashing-magazine.png) 45 | 46 | ### Bootstrap 47 | 48 | ![Bootstrap color sort example](/examples/bootstrap.png) 49 | 50 | ### Zurb Foundation 51 | 52 | ![Zurb Foundation color sort example](/examples/foundation.png) 53 | 54 | ### Project Wallace 55 | 56 | ![Project Wallace color sort example](/examples/project-wallace.png) 57 | 58 | ## Related projects 59 | 60 | - [CSS Analyzer](https://github.com/projectwallace/css-analyzer) - Generate 61 | analysis for a string of CSS 62 | - [Wallace](https://github.com/projectwallace/wallace-cli) - CLI tool for 63 | @projectwallace/css-analyzer 64 | - [Constyble](https://github.com/projectwallace/constyble) - A CSS complexity linter, based on css-analyzer. Don't let your CSS grow beyond the thresholds that you provide. 65 | 66 | ## License 67 | 68 | MIT © Bart Veneman 69 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { convert, sort, sortFn, compare } from './index.js' 4 | 5 | test('it exposes a basic sort function', () => { 6 | assert.is(typeof sort, 'function') 7 | }) 8 | 9 | test('it exposes a convert function', () => { 10 | assert.is(typeof convert, 'function') 11 | }) 12 | 13 | test('it exposes a sortFn', () => { 14 | assert.is(typeof sortFn, 'function') 15 | }) 16 | 17 | test('it exposes a compare function', () => { 18 | assert.is(typeof compare, 'function') 19 | }) 20 | 21 | test('the convert fn converts colors to an HSLA object', () => { 22 | const colors = [ 23 | 'red', 24 | 'hsla(0, 100%, 50%, 1)', 25 | 'hsl(0, 100%, 50%)', 26 | 'rgb(255, 0, 0)', 27 | 'rgba(255, 0, 0, 1)', 28 | 'oklch(62.8% 0.25768330773615683 29.2338851923426)' 29 | ] 30 | 31 | for (let color of colors) { 32 | let converted = convert(color) 33 | // Making sure most colors are mostly within the range 34 | assert.ok(converted.hue >= 0 && converted.hue <= 0.01, `Failed hue for '${color}', got ${converted.hue}`) 35 | assert.ok(converted.saturation >= 99.9 && converted.saturation <= 100.02, `Failed saturation for '${color}', got ${converted.saturation}`) 36 | assert.ok(converted.lightness >= 49.9 && converted.lightness <= 50.02, `Failed lightness for '${color}', got ${converted.lightness}`) 37 | assert.equal(converted.alpha, 1, `Failed alpha for '${color}'`) 38 | assert.equal(converted.authored, color, `Failed authored for '${color}'`) 39 | } 40 | }) 41 | 42 | test('invalid colors return a default object', () => { 43 | const colors = [ 44 | 'invalid', 45 | 'hsl(0, 0, 0)', 46 | 'rgb(0 0 0 1)', 47 | 'rgb(a, b, c, 0)', 48 | ] 49 | 50 | for (let color of colors) { 51 | assert.equal(convert(color), { 52 | hue: 0, 53 | saturation: 0, 54 | lightness: 0, 55 | alpha: 0, 56 | authored: color 57 | }, `Failed convert for '${color}'`) 58 | } 59 | 60 | assert.equal(convert('rgb(NaN NaN NaN / 1)'), { 61 | hue: 0, 62 | saturation: 0, 63 | lightness: 0, 64 | alpha: 1, 65 | authored: 'rgb(NaN NaN NaN / 1)' 66 | }) 67 | }) 68 | 69 | test('comparing two colors', () => { 70 | const a = convert('red') 71 | const b = convert('blue') 72 | const actual = compare(a, b) 73 | assert.ok(actual < 0) 74 | }) 75 | 76 | test('comparing identical colors', () => { 77 | const a = convert('red') 78 | const b = convert('red') 79 | const actual = compare(a, b) 80 | assert.equal(actual, 0) 81 | }) 82 | 83 | test('Colors are sorted by Hue', () => { 84 | const colors = [ 85 | 'hsl(0, 100%, 50%)', 86 | 'hsl(200, 100%, 50%)', 87 | 'hsl(50, 100%, 50%)', 88 | 'hsl(10, 100%, 50%)', 89 | 'hsl(100, 100%, 50%)' 90 | ] 91 | const expected = [ 92 | 'hsl(0, 100%, 50%)', 93 | 'hsl(10, 100%, 50%)', 94 | 'hsl(50, 100%, 50%)', 95 | 'hsl(100, 100%, 50%)', 96 | 'hsl(200, 100%, 50%)' 97 | ] 98 | const actual = sort(colors) 99 | 100 | assert.equal(actual, expected) 101 | }) 102 | 103 | test('Colors are sorted by Hue, then by saturation', () => { 104 | const colors = [ 105 | 'hsl(0, 100%, 50%)', 106 | 'hsl(0, 50%, 50%)', 107 | 'hsl(50, 20%, 50%)', 108 | 'hsl(50, 100%, 50%)' 109 | ] 110 | const expected = [ 111 | 'hsl(0, 50%, 50%)', 112 | 'hsl(0, 100%, 50%)', 113 | 'hsl(50, 20%, 50%)', 114 | 'hsl(50, 100%, 50%)' 115 | ] 116 | const actual = sort(colors) 117 | 118 | assert.equal(actual, expected) 119 | }) 120 | 121 | test('Grey-ish values are shifted to the end (lightest first)', () => { 122 | const colors = [ 123 | 'hsl(0, 0%, 0%)', // Black 124 | 'hsl(0, 100%, 50%)', // Red, 125 | 'hsl(0, 0%, 100%)', // White 126 | 'hsl(240, 100%, 50%)' // Blue 127 | ] 128 | const expected = [ 129 | 'hsl(0, 100%, 50%)', // Red 130 | 'hsl(240, 100%, 50%)', // Blue 131 | 'hsl(0, 0%, 100%)', // White 132 | 'hsl(0, 0%, 0%)', // Black 133 | ] 134 | const actual = sort(colors) 135 | 136 | assert.equal(actual, expected) 137 | }) 138 | 139 | test('Grey-ish colors are sorted by Lightness', () => { 140 | // The key here is that saturation (the middle value in HSL) 141 | // equals 0 142 | const colors = [ 143 | '#000', 144 | '#fff', 145 | '#eee', 146 | '#555', 147 | '#222' 148 | ] 149 | const expected = [ 150 | '#fff', 151 | '#eee', 152 | '#555', 153 | '#222', 154 | '#000' 155 | ] 156 | const actual = sort(colors) 157 | 158 | assert.equal(actual, expected) 159 | }) 160 | 161 | test('Grey-ish colors are sorted by Lightness, then by Alpha', () => { 162 | const colors = [ 163 | 'hsla(0, 0%, 20%, 1)', 164 | 'hsla(0, 0%, 10%, 1)', 165 | 'hsla(0, 0%, 10%, 0)', 166 | 'hsla(0, 0%, 0%, 0)' 167 | ] 168 | const expected = [ 169 | 'hsla(0, 0%, 20%, 1)', 170 | 'hsla(0, 0%, 10%, 1)', 171 | 'hsla(0, 0%, 10%, 0)', 172 | 'hsla(0, 0%, 0%, 0)', 173 | ] 174 | const actual = sort(colors) 175 | 176 | assert.equal(actual, expected) 177 | }) 178 | 179 | test('colors with identical transparency are sorted alphabetically', () => { 180 | const colors = [ 181 | 'RGBA(255, 0, 0, 0.5)', 182 | 'rgba(255, 0, 0, 0.5)', 183 | ] 184 | const actual = sort(colors) 185 | const expected = [ 186 | 'RGBA(255, 0, 0, 0.5)', 187 | 'rgba(255, 0, 0, 0.5)', 188 | ] 189 | assert.equal(actual, expected) 190 | }) 191 | 192 | test('Fully transparent colors are sorted along their opaque companions', () => { 193 | const colors = ['rgba(255, 0, 0, 0)', 'hsla(0, 100%, 50%, 0.1)', 'red'] 194 | const actual = sort(colors) 195 | const expected = ['red', 'hsla(0, 100%, 50%, 0.1)', 'rgba(255, 0, 0, 0)'] 196 | 197 | assert.equal(actual, expected) 198 | }) 199 | 200 | test('smoke test', () => { 201 | const colors = [ 202 | '#4b4747', 203 | '#f00', 204 | '#d70c0b', 205 | '#f22b24', 206 | '#ff6930', 207 | '#eb6c1e', 208 | '#eb6d1e', 209 | '#f57917', 210 | '#ff8a0a', 211 | '#f7a336', 212 | '#feb95a', 213 | '#eca920', 214 | '#f1c15d', 215 | '#f1c260', 216 | '#ff0', 217 | '#c8d05b', 218 | '#ccd557', 219 | '#d2ff52', 220 | '#10ac47', 221 | '#04a03b', 222 | '#03fff3', 223 | '#38d7df', 224 | '#25bbc3', 225 | '#15b8ec', 226 | '#00adea', 227 | '#8e34c9', 228 | '#9a3dd1', 229 | '#cd66f6', 230 | '#fff', 231 | 'rgba(255,255,255,0.2)', 232 | 'rgba(255,255,255,0.07)', 233 | '#f9f9f9', 234 | '#f4f4f4', 235 | '#f2f2f2', 236 | '#e4e4e4', 237 | '#ddd', 238 | '#c0c0c0', 239 | '#666', 240 | '#4a4a4a', 241 | '#1d1d1d', 242 | '#0d0d0d', 243 | '#000', 244 | 'rgba(0,0,0,0.8)', 245 | 'rgba(0,0,0,0.6)', 246 | 'rgba(0,0,0,0.4)', 247 | 'rgba(0,0,0,0.1)', 248 | 'rgba(0,0,0,0.05)' 249 | ] 250 | const expected = [...colors] 251 | const actual = sort(colors) 252 | 253 | assert.equal(actual, expected) 254 | }) 255 | 256 | test.run() 257 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Base options: 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "verbatimModuleSyntax": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "moduleDetection": "force", 10 | 11 | // Strictness 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | 15 | // Type checking, not transpiling 16 | "module": "ESNext", 17 | "moduleResolution": "bundler", 18 | "allowSyntheticDefaultImports": true, 19 | "noEmit": true, 20 | 21 | // Code runs in the DOM 22 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 23 | }, 24 | "include": [ 25 | "index.js" 26 | ], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig } from "vite" 3 | import dts from "vite-plugin-dts" 4 | import { codecovVitePlugin } from "@codecov/vite-plugin" 5 | 6 | export default defineConfig({ 7 | build: { 8 | lib: { 9 | entry: resolve(__dirname, "index.js"), 10 | formats: ['es'] 11 | }, 12 | rollupOptions: { 13 | // make sure to externalize deps that shouldn't be bundled 14 | // into your library 15 | external: [ 16 | 'colorjs.io/fn' 17 | ], 18 | }, 19 | }, 20 | plugins: [ 21 | dts(), 22 | codecovVitePlugin({ 23 | enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, 24 | bundleName: "formatCss", 25 | uploadToken: process.env.CODECOV_TOKEN, 26 | }), 27 | ], 28 | }) 29 | --------------------------------------------------------------------------------