├── src ├── CNAME ├── styles │ ├── styles.scss │ ├── _fonts.scss │ ├── _variables.scss │ ├── _ads.scss │ ├── _dark.scss │ └── _main.scss ├── favicon.ico ├── icon-192.png ├── icon-512.png ├── icon-mask.png ├── images │ └── share.jpg ├── apple-touch-icon.png ├── fonts │ ├── WorkSans-Black.woff2 │ ├── WorkSans-Bold.woff2 │ ├── WorkSans-Medium.woff2 │ └── WorkSans-Regular.woff2 ├── google6440a06758b62a5f.njk ├── 404.njk ├── _data │ └── site.json ├── manifest.webmanifest ├── _includes │ ├── partials │ │ ├── icons │ │ │ ├── check.njk │ │ │ ├── x.njk │ │ │ ├── plus.njk │ │ │ ├── moon.njk │ │ │ ├── pencil.njk │ │ │ ├── hash.njk │ │ │ ├── download.njk │ │ │ ├── color-picker.njk │ │ │ ├── photo.njk │ │ │ ├── share.njk │ │ │ ├── figma.njk │ │ │ ├── copy.njk │ │ │ ├── color-filter.njk │ │ │ └── sun-high.njk │ │ ├── footer.njk │ │ ├── icon-templates.njk │ │ ├── scripts.njk │ │ ├── share-dialog.njk │ │ ├── export-dialog.njk │ │ ├── head.njk │ │ └── header.njk │ └── layouts │ │ └── base.njk ├── icon.svg ├── vendor │ ├── js │ │ ├── prism-json.min.js │ │ ├── prism-css.min.js │ │ ├── anchor.js │ │ ├── clipboard.js │ │ └── coloris.min.js │ └── css │ │ ├── prism-tomorrow.min.css │ │ ├── prism.min.css │ │ ├── normalize.min.css │ │ └── coloris.min.css ├── sitemap.njk ├── js │ ├── link-hover.js │ ├── clipboard-init.js │ ├── theme-toggle.js │ ├── color-utils.js │ ├── export-naming.js │ ├── app.js │ ├── share-ui.js │ ├── color-picker.js │ └── export-ui.js ├── index.njk └── about.njk ├── .gitignore ├── assets ├── home-dark.png ├── home-light.png ├── palettes-dark.png └── palettes-light.png ├── .github ├── FUNDING.yml └── workflows │ └── deploy.yml ├── .eleventy.js ├── LICENSE ├── package.json └── README.md /src/CNAME: -------------------------------------------------------------------------------- 1 | maketintsandshades.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | _site/ -------------------------------------------------------------------------------- /src/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'fonts'; 2 | @use 'main'; 3 | @use 'ads'; 4 | @use 'dark'; -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/icon-192.png -------------------------------------------------------------------------------- /src/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/icon-512.png -------------------------------------------------------------------------------- /src/icon-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/icon-mask.png -------------------------------------------------------------------------------- /assets/home-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/assets/home-dark.png -------------------------------------------------------------------------------- /src/images/share.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/images/share.jpg -------------------------------------------------------------------------------- /assets/home-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/assets/home-light.png -------------------------------------------------------------------------------- /assets/palettes-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/assets/palettes-dark.png -------------------------------------------------------------------------------- /assets/palettes-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/assets/palettes-light.png -------------------------------------------------------------------------------- /src/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/apple-touch-icon.png -------------------------------------------------------------------------------- /src/fonts/WorkSans-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/fonts/WorkSans-Black.woff2 -------------------------------------------------------------------------------- /src/fonts/WorkSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/fonts/WorkSans-Bold.woff2 -------------------------------------------------------------------------------- /src/fonts/WorkSans-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/fonts/WorkSans-Medium.woff2 -------------------------------------------------------------------------------- /src/fonts/WorkSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelstone/tints-and-shades/HEAD/src/fonts/WorkSans-Regular.woff2 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://buymeacoffee.com/edelstone", "https://venmo.com/michaeledelstone", "https://cash.app/$edelstone", "https://paypal.me/edelstone"] 2 | -------------------------------------------------------------------------------- /src/google6440a06758b62a5f.njk: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /google6440a06758b62a5f.html 3 | excludeFromSitemap: true 4 | --- 5 | google-site-verification: google6440a06758b62a5f.html -------------------------------------------------------------------------------- /src/404.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/base 3 | title: 404 4 | permalink: /404.html 5 | excludeFromSitemap: true 6 | --- 7 | 8 |
9 |

404

10 |

Sorry, that page doesn't exist.

11 |
-------------------------------------------------------------------------------- /src/_data/site.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tint and Shade Generator", 3 | "url": "https://maketintsandshades.com", 4 | "description": "Easily make tints and shades that match the output of Chrome DevTools, Sass, Less, and PostCSS.", 5 | "shareImage": "https://maketintsandshades.com/images/share.jpg" 6 | } -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" }, 4 | { "src": "/icon-mask.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }, 5 | { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" } 6 | ] 7 | } -------------------------------------------------------------------------------- /src/_includes/partials/icons/check.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/x.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/plus.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/moon.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/_includes/partials/footer.njk: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/pencil.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/hash.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/vendor/js/prism-json.min.js: -------------------------------------------------------------------------------- 1 | Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; -------------------------------------------------------------------------------- /src/_includes/layouts/base.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% include "partials/head.njk" %} 6 | 7 | 8 | 9 | {% include "partials/header.njk" %} 10 |
11 | {{ content | safe }} 12 |
13 | {% include "partials/footer.njk" %} 14 | {% include "partials/icon-templates.njk" %} 15 | {% include "partials/scripts.njk" %} 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/download.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/color-picker.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/sitemap.njk: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /sitemap.xml 3 | excludeFromSitemap: true 4 | --- 5 | 6 | 7 | {% for page in collections.all %} 8 | {%- if page.url and page.data.excludeFromSitemap != true -%} 9 | 10 | {{ site.url }}{{ page.url | url }} 11 | {{ page.date.toISOString() }} 12 | 13 | {%- endif %} 14 | {% endfor %} 15 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/photo.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/share.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/figma.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/copy.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/color-filter.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/styles/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Work Sans'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url('/fonts/WorkSans-Regular.woff2') format('woff2'); 6 | } 7 | 8 | @font-face { 9 | font-family: 'Work Sans'; 10 | font-style: normal; 11 | font-weight: 500; 12 | src: url('/fonts/WorkSans-Medium.woff2') format('woff2'); 13 | } 14 | 15 | @font-face { 16 | font-family: 'Work Sans'; 17 | font-style: normal; 18 | font-weight: 700; 19 | src: url('/fonts/WorkSans-Bold.woff2') format('woff2'); 20 | } 21 | 22 | @font-face { 23 | font-family: 'Work Sans'; 24 | font-style: normal; 25 | font-weight: 900; 26 | src: url('/fonts/WorkSans-Black.woff2') format('woff2'); 27 | } 28 | -------------------------------------------------------------------------------- /src/_includes/partials/icons/sun-high.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.eleventy.js: -------------------------------------------------------------------------------- 1 | module.exports = function (eleventyConfig) { 2 | eleventyConfig.addPassthroughCopy("src/CNAME"); 3 | eleventyConfig.addPassthroughCopy("src/images"); 4 | eleventyConfig.addPassthroughCopy("src/fonts"); 5 | eleventyConfig.addPassthroughCopy("src/js"); 6 | eleventyConfig.addPassthroughCopy("src/vendor"); 7 | eleventyConfig.addPassthroughCopy("src/*.svg"); 8 | eleventyConfig.addPassthroughCopy("src/*.png"); 9 | eleventyConfig.addPassthroughCopy("src/*.ico"); 10 | eleventyConfig.addPassthroughCopy("src/*.webmanifest"); 11 | 12 | eleventyConfig.setServerOptions({ 13 | watch: ["./_site/css/**/*.css"] 14 | }); 15 | 16 | return { 17 | dir: { 18 | input: "src", 19 | output: "_site" 20 | } 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/_includes/partials/icon-templates.njk: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/js/link-hover.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const linkHoverColors = ["#e96443", "#ca228e"]; 3 | let nextHoverIndex = 0; 4 | 5 | const getNextHoverColor = () => { 6 | const color = linkHoverColors[nextHoverIndex]; 7 | nextHoverIndex = (nextHoverIndex + 1) % linkHoverColors.length; 8 | return color; 9 | }; 10 | 11 | const wireAlternatingLinkHover = () => { 12 | const links = Array.from(document.querySelectorAll("a")); 13 | if (!links.length) return; 14 | 15 | links.forEach((link) => { 16 | link.addEventListener("mouseenter", () => { 17 | link.style.borderColor = getNextHoverColor(); 18 | }); 19 | link.addEventListener("mouseleave", () => { 20 | link.style.borderColor = ""; 21 | }); 22 | }); 23 | }; 24 | 25 | if (document.readyState === "loading") { 26 | document.addEventListener("DOMContentLoaded", wireAlternatingLinkHover); 27 | } else { 28 | wireAlternatingLinkHover(); 29 | } 30 | })(); 31 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Eleventy site to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: "20" 26 | cache: "npm" 27 | 28 | - run: npm ci 29 | - run: npm run build 30 | 31 | - uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: ./_site 34 | 35 | deploy: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | environment: 39 | name: github-pages 40 | url: ${{ steps.deployment.outputs.page_url }} 41 | steps: 42 | - id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /src/_includes/partials/scripts.njk: -------------------------------------------------------------------------------- 1 | {% if main %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | 18 | {% if docs %} 19 | 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2026 Michael Edelstone 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 | -------------------------------------------------------------------------------- /src/js/clipboard-init.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const clipboard = new ClipboardJS(".hex-color"); 3 | 4 | clipboard.on("success", (e) => { 5 | const copiedText = e.text; 6 | const status = document.getElementById("copy-status"); 7 | const target = e.trigger; 8 | 9 | target.classList.add("copy-locked"); 10 | target.setAttribute("aria-disabled", "true"); 11 | target.setAttribute("tabindex", "-1"); 12 | 13 | target.classList.add("copied"); 14 | target.setAttribute("aria-label", `Copied ${copiedText} to clipboard`); 15 | 16 | if (status) { 17 | status.textContent = `Copied ${copiedText} to clipboard.`; 18 | } 19 | 20 | requestAnimationFrame(() => target.focus()); 21 | 22 | setTimeout(() => { 23 | target.classList.remove("copied"); 24 | target.classList.remove("copy-locked"); 25 | target.removeAttribute("aria-disabled"); 26 | target.setAttribute("tabindex", "0"); 27 | }, 1500); 28 | 29 | setTimeout(() => { 30 | target.setAttribute("aria-label", "Color swatch"); 31 | if (status) status.textContent = ""; 32 | }, 4500); 33 | e.clearSelection(); 34 | }); 35 | })(); 36 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $white: #ffffff; 2 | $black: #000000; 3 | $black-transparent: rgba(0, 0, 0, .3); 4 | $orange: #e96443; 5 | $magenta: #ca228e; 6 | $alert: #cc0000; 7 | $success: #009900; 8 | $gray-50: #f0f0f0; 9 | $gray-100: #e6e6e6; 10 | $gray-200: #dbdbdb; 11 | $gray-300: #a9a9a9; 12 | $gray-400: #767676; 13 | $gray-500: #4d4d4d; 14 | $gray-600: #1a1a1a; 15 | 16 | $radius-main: 6px; 17 | $shadow-main: 0 4px 10px 0 rgba(0, 0, 0, .25); 18 | 19 | $font-primary: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 20 | $font-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 21 | 22 | @mixin breakpoint($point, $value: "") { 23 | @if $point =='large' { 24 | @media only screen and (max-width: 1000px) { 25 | @content; 26 | } 27 | } 28 | 29 | @else if $point =='medium' { 30 | @media only screen and (max-width: 500px) { 31 | @content; 32 | } 33 | } 34 | 35 | @else if $point =='small' { 36 | @media only screen and (max-width: 350px) { 37 | @content; 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/_includes/partials/share-dialog.njk: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Share link

4 | 9 |
10 |
11 | 12 | 13 | 21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /src/vendor/js/prism-css.min.js: -------------------------------------------------------------------------------- 1 | !function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); -------------------------------------------------------------------------------- /src/vendor/css/prism-tomorrow.min.css: -------------------------------------------------------------------------------- 1 | code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tints-and-shades", 3 | "version": "6.0.0", 4 | "description": "Easily make tints and shades that match the output of Chrome DevTools, Sass, Less, and PostCSS.", 5 | "main": "index.js", 6 | "scripts": { 7 | "watch:readme": "nodemon --watch src/about.njk --watch build-readme.js --exec \"npm run build:readme\"", 8 | "build:readme": "node build-readme.js", 9 | "watch:css": "sass --watch src/styles:_site/css --style=expanded", 10 | "build:css": "sass src/styles:_site/css --style=compressed", 11 | "watch:11ty": "eleventy --serve", 12 | "build:11ty": "eleventy", 13 | "build": "npm run build:css && npm run build:11ty", 14 | "start": "concurrently \"npm run watch:css\" \"npm run watch:readme\" \"npm run watch:11ty\"", 15 | "deploy": "npm run build && gh-pages -d _site" 16 | }, 17 | "keywords": [ 18 | "colors", 19 | "hex", 20 | "tints", 21 | "shades", 22 | "palettes", 23 | "design" 24 | ], 25 | "author": "Michael Edelstone (https://michaeledelstone.com)", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "@11ty/eleventy": "^3.1.2", 29 | "@11ty/eleventy-dev-server": "^2.0.4", 30 | "concurrently": "^9.0.1", 31 | "gh-pages": "^6.1.1", 32 | "nodemon": "^3.1.4", 33 | "sass": "^1.78.0", 34 | "turndown": "^7.2.0" 35 | }, 36 | "dependencies": { 37 | "@melloware/coloris": "^0.25.0", 38 | "color-name-list": "^14.11.0", 39 | "prismjs": "^1.29.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/vendor/css/prism.min.css: -------------------------------------------------------------------------------- 1 | code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} -------------------------------------------------------------------------------- /src/vendor/css/normalize.min.css: -------------------------------------------------------------------------------- 1 | html{line-height:1.15;-webkit-text-size-adjust:100%;}body{margin:0;}h1{font-size:2em;margin:0.67em 0;}hr{box-sizing:content-box;height:0;overflow:visible;}pre{font-family:monospace,monospace;font-size:1em;}a{background-color:transparent;}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted;}b,strong{font-weight:bolder;}code,kbd,samp{font-family:monospace,monospace;font-size:1em;}small{font-size:80%;}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;}sub{bottom:-0.25em;}sup{top:-0.5em;}img{border-style:none;}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0;}button,input{overflow:visible;}button,select{text-transform:none;}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button;}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0;}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText;}fieldset{padding:0.35em 0.75em 0.625em;}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal;}progress{vertical-align:baseline;}textarea{overflow:auto;}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0;}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto;}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px;}[type="search"]::-webkit-search-decoration{-webkit-appearance:none;}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit;}details{display:block;}summary{display:list-item;}template{display:none;}[hidden]{display:none;} 2 | -------------------------------------------------------------------------------- /src/_includes/partials/export-dialog.njk: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Export palettes

4 | 9 |
10 |
11 | {% set formats = [ 12 | { id: "hex", label: "Hex" }, 13 | { id: "hex-hash", label: "#Hex" }, 14 | { id: "rgb", label: "RGB" }, 15 | { id: "css", label: "CSS" }, 16 | { id: "json", label: "JSON" } 17 | ] %} 18 | {% for format in formats %} 19 | {% set isActive = loop.first %} 20 | 21 | {% endfor %} 22 |
23 |
24 |
25 | 			
26 | 		
27 |
28 |
29 | 37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /src/_includes/partials/head.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% if page.url != '/404.html' %} 20 | 21 | 22 | {% endif %} 23 | 24 | 25 | 26 | 27 | 40 | 41 | 42 | 43 | 44 | 45 | {% if not main %}{{ title }} | {% endif %}{{ site.name }} 46 | -------------------------------------------------------------------------------- /src/styles/_ads.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as *; 2 | 3 | .ads-primary, 4 | .ads-secondary { 5 | display: none; 6 | } 7 | 8 | .ads-primary { 9 | margin-bottom: 10rem; 10 | padding: 0 2rem; 11 | } 12 | 13 | .ads-primary:has(#carbonads), 14 | .ads-primary:has(#carbon-responsive), 15 | .ads-secondary:has(#carbonads), 16 | .ads-secondary:has(#carbon-responsive) { 17 | display: block; 18 | } 19 | 20 | #carbonads { 21 | width: 100%; 22 | display: flex; 23 | justify-content: center; 24 | 25 | a { 26 | border: none; 27 | } 28 | 29 | #carbon-cover { 30 | font-family: $font-primary; 31 | 32 | .carbon { 33 | 34 | &-reveal, 35 | &-large-image { 36 | border-radius: $radius-main; 37 | } 38 | 39 | &-tagline { 40 | font-weight: 400; 41 | max-inline-size: 18ch; 42 | } 43 | 44 | &-cta { 45 | font-weight: 500; 46 | color: $black; 47 | border-radius: $radius-main; 48 | } 49 | 50 | &-footer { 51 | 52 | button, 53 | .carbon-share { 54 | visibility: hidden; 55 | } 56 | 57 | .carbon-via { 58 | color: $gray-400; 59 | } 60 | } 61 | } 62 | } 63 | 64 | #carbon-responsive { 65 | font-family: $font-primary; 66 | gap: .25rem; 67 | 68 | a { 69 | color: $black; 70 | } 71 | 72 | img { 73 | border-radius: $radius-main; 74 | } 75 | 76 | .carbon-responsive-wrap { 77 | background-color: $gray-50; 78 | border-color: $gray-50; 79 | border-radius: $radius-main; 80 | } 81 | 82 | .carbon-poweredby { 83 | opacity: .75; 84 | } 85 | } 86 | } 87 | 88 | .darkmode-active { 89 | #carbonads { 90 | #carbon-cover { 91 | .carbon-footer { 92 | .carbon-via { 93 | color: $gray-300; 94 | } 95 | } 96 | } 97 | 98 | #carbon-responsive { 99 | .carbon-responsive-wrap { 100 | background-color: $black-transparent; 101 | border-color: $black-transparent; 102 | } 103 | 104 | a { 105 | color: $gray-100; 106 | } 107 | } 108 | } 109 | } 110 | 111 | .ads-secondary { 112 | margin-top: 2rem; 113 | } -------------------------------------------------------------------------------- /src/_includes/partials/header.njk: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if main %} 4 | 5 | {% else %} 6 | 7 | {% endif %} 8 |
9 | 16 |
17 | New!{% include "partials/icons/figma.njk" %}plugin 18 | 19 | 24 | 25 |
26 |
27 |

Tint & Shade Generator

28 | {% if not main %} 29 |
30 | 31 |
32 | {% endif %} 33 |
34 |
-------------------------------------------------------------------------------- /src/js/theme-toggle.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const storageKey = "theme"; 3 | const root = document.documentElement; 4 | const toggle = document.getElementById("darkmode-toggle"); 5 | const toggleText = document.getElementById("darkmode-text-toggle"); 6 | const prismLightThemeLink = document.getElementById("prism-light-theme"); 7 | const prismDarkThemeLink = document.getElementById("prism-dark-theme"); 8 | 9 | const updatePrismThemeLinks = (isDark) => { 10 | if (typeof isDark !== "boolean") return; 11 | if (prismLightThemeLink) { 12 | prismLightThemeLink.disabled = isDark; 13 | } 14 | if (prismDarkThemeLink) { 15 | prismDarkThemeLink.disabled = !isDark; 16 | } 17 | }; 18 | 19 | const getStoredTheme = () => { 20 | try { 21 | return localStorage.getItem(storageKey); 22 | } catch (e) { 23 | return null; 24 | } 25 | }; 26 | 27 | const setStoredTheme = (theme) => { 28 | try { 29 | localStorage.setItem(storageKey, theme); 30 | } catch (e) { 31 | // ignore 32 | } 33 | }; 34 | 35 | const applyTheme = (theme, persist = false) => { 36 | const isDark = theme === "dark"; 37 | root.classList.toggle("darkmode-active", isDark); 38 | updatePrismThemeLinks(isDark); 39 | if (toggle) { 40 | toggle.setAttribute("aria-pressed", String(isDark)); 41 | toggle.dataset.themeState = isDark ? "dark" : "light"; 42 | toggle.setAttribute("aria-label", isDark ? "Light mode" : "Dark mode"); 43 | } 44 | if (toggleText) toggleText.innerText = isDark ? "Light mode" : "Dark mode"; 45 | if (persist) setStoredTheme(theme); 46 | }; 47 | 48 | const preferred = getStoredTheme(); 49 | const systemPrefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; 50 | const initialTheme = preferred || (systemPrefersDark ? "dark" : "light"); 51 | applyTheme(initialTheme); 52 | 53 | const ensureThemeApplied = () => { 54 | const storedTheme = getStoredTheme(); 55 | const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; 56 | const nextTheme = storedTheme || (prefersDark ? "dark" : "light"); 57 | if (nextTheme === "dark" && !root.classList.contains("darkmode-active")) { 58 | applyTheme("dark"); 59 | return; 60 | } 61 | if (nextTheme === "light" && root.classList.contains("darkmode-active")) { 62 | applyTheme("light"); 63 | } 64 | }; 65 | 66 | const observer = new MutationObserver(() => { 67 | ensureThemeApplied(); 68 | }); 69 | observer.observe(root, { attributes: true, attributeFilter: ["class"] }); 70 | 71 | if (toggle) { 72 | toggle.addEventListener("click", () => { 73 | const nextTheme = root.classList.contains("darkmode-active") ? "light" : "dark"; 74 | applyTheme(nextTheme, true); 75 | }); 76 | } 77 | })(); 78 | -------------------------------------------------------------------------------- /src/index.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/base 3 | title: Home 4 | permalink: / 5 | main: true 6 | --- 7 | 8 |
9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 | 23 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | 56 |
57 | {# Screen reader-only live region for copy confirmations #} 58 |
59 |
60 | 61 | {% include "partials/export-dialog.njk" %} 62 | {% include "partials/share-dialog.njk" %} 63 | -------------------------------------------------------------------------------- /src/js/color-utils.js: -------------------------------------------------------------------------------- 1 | const pad = (number, length) => { 2 | let str = number.toString(); 3 | while (str.length < length) { 4 | str = '0' + str; 5 | } 6 | return str; 7 | }; 8 | 9 | const hexToRGB = (colorValue) => ({ 10 | red: parseInt(colorValue.substr(0, 2), 16), 11 | green: parseInt(colorValue.substr(2, 2), 16), 12 | blue: parseInt(colorValue.substr(4, 2), 16) 13 | }); 14 | 15 | const intToHex = (rgbint) => pad(Math.min(Math.max(Math.round(rgbint), 0), 255).toString(16), 2); 16 | 17 | const rgbToHex = (rgb) => intToHex(rgb.red) + intToHex(rgb.green) + intToHex(rgb.blue); 18 | 19 | const DEFAULT_STEPS = 10; 20 | const clampSteps = (steps = DEFAULT_STEPS) => { 21 | const parsed = parseInt(steps, 10); 22 | if (Number.isNaN(parsed)) return DEFAULT_STEPS; 23 | return Math.min(Math.max(parsed, 1), 20); 24 | }; 25 | 26 | const mixChannel = (from, to, ratio) => from + (to - from) * ratio; 27 | 28 | const calculateScale = (colorValue, steps, mixFn) => { 29 | const totalSteps = clampSteps(steps); 30 | const color = hexToRGB(colorValue); 31 | const values = []; 32 | 33 | for (let i = 0; i < totalSteps; i++) { 34 | const ratio = i / totalSteps; 35 | const rgb = mixFn(color, ratio); 36 | values.push({ 37 | hex: rgbToHex(rgb), 38 | ratio, 39 | percent: Number((ratio * 100).toFixed(1)) 40 | }); 41 | } 42 | 43 | return values; 44 | }; 45 | 46 | const rgbShade = (rgb, ratio) => ({ 47 | red: mixChannel(rgb.red, 0, ratio), 48 | green: mixChannel(rgb.green, 0, ratio), 49 | blue: mixChannel(rgb.blue, 0, ratio) 50 | }); 51 | 52 | const rgbTint = (rgb, ratio) => ({ 53 | red: mixChannel(rgb.red, 255, ratio), 54 | green: mixChannel(rgb.green, 255, ratio), 55 | blue: mixChannel(rgb.blue, 255, ratio) 56 | }); 57 | 58 | const calculateShades = (colorValue, steps = DEFAULT_STEPS) => calculateScale(colorValue, steps, rgbShade); 59 | const calculateTints = (colorValue, steps = DEFAULT_STEPS) => calculateScale(colorValue, steps, rgbTint); 60 | 61 | const rgbToHsl = (rgb) => { 62 | const r = rgb.red / 255; 63 | const g = rgb.green / 255; 64 | const b = rgb.blue / 255; 65 | const max = Math.max(r, g, b); 66 | const min = Math.min(r, g, b); 67 | let hue = 0; 68 | let saturation = 0; 69 | const lightness = (max + min) / 2; 70 | 71 | if (max !== min) { 72 | const delta = max - min; 73 | saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); 74 | 75 | switch (max) { 76 | case r: 77 | hue = ((g - b) / delta + (g < b ? 6 : 0)) * 60; 78 | break; 79 | case g: 80 | hue = ((b - r) / delta + 2) * 60; 81 | break; 82 | default: 83 | hue = ((r - g) / delta + 4) * 60; 84 | } 85 | } 86 | 87 | return { 88 | hue: (hue + 360) % 360, 89 | saturation, 90 | lightness 91 | }; 92 | }; 93 | 94 | const hueToRgb = (p, q, t) => { 95 | if (t < 0) t += 1; 96 | if (t > 1) t -= 1; 97 | if (t < 1 / 6) return p + (q - p) * 6 * t; 98 | if (t < 1 / 2) return q; 99 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 100 | return p; 101 | }; 102 | 103 | const hslToRgb = ({ hue, saturation, lightness }) => { 104 | const h = ((hue % 360) + 360) % 360 / 360; 105 | const s = Math.min(Math.max(saturation, 0), 1); 106 | const l = Math.min(Math.max(lightness, 0), 1); 107 | 108 | if (s === 0) { 109 | const value = Math.round(l * 255); 110 | return { red: value, green: value, blue: value }; 111 | } 112 | 113 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 114 | const p = 2 * l - q; 115 | 116 | return { 117 | red: Math.round(hueToRgb(p, q, h + 1 / 3) * 255), 118 | green: Math.round(hueToRgb(p, q, h) * 255), 119 | blue: Math.round(hueToRgb(p, q, h - 1 / 3) * 255) 120 | }; 121 | }; 122 | 123 | const calculateComplementaryHex = (colorValue) => { 124 | if (typeof colorValue !== "string") return null; 125 | const normalized = colorValue.replace(/^#/, "").trim().toLowerCase(); 126 | if (normalized.length !== 6) return null; 127 | const rgb = hexToRGB(normalized); 128 | const hsl = rgbToHsl(rgb); 129 | const complementHue = (hsl.hue + 180) % 360; 130 | const complementaryRgb = hslToRgb({ hue: complementHue, saturation: hsl.saturation, lightness: hsl.lightness }); 131 | return rgbToHex(complementaryRgb); 132 | }; 133 | 134 | // Expose for reuse 135 | window.colorUtils = { 136 | pad, 137 | hexToRGB, 138 | intToHex, 139 | rgbToHex, 140 | rgbToHsl, 141 | hslToRgb, 142 | rgbShade, 143 | rgbTint, 144 | calculateScale, 145 | calculateShades, 146 | calculateTints, 147 | calculateComplementaryHex 148 | }; 149 | -------------------------------------------------------------------------------- /src/js/export-naming.js: -------------------------------------------------------------------------------- 1 | const slugify = (value) => value 2 | .toLowerCase() 3 | .normalize("NFKD") 4 | .replace(/[\u0300-\u036f]/g, "") 5 | .replace(/[’']/g, "") 6 | .replace(/[^a-z0-9]+/g, "-") 7 | .replace(/^-+|-+$/g, "") 8 | .replace(/-+/g, "-"); 9 | 10 | const formatLabelForDisplay = (value) => { 11 | if (!value || typeof value !== "string") return ""; 12 | const normalized = value.trim(); 13 | if (!normalized) return ""; 14 | if (!/^color-/i.test(normalized)) { 15 | return normalized; 16 | } 17 | return normalized 18 | .split(/[-_]+/) 19 | .filter(Boolean) 20 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 21 | .join(" "); 22 | }; 23 | 24 | const rgbToLinear = (value) => { 25 | const v = value / 255; 26 | return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); 27 | }; 28 | 29 | const rgbToXyz = ({ red, green, blue }) => { 30 | const r = rgbToLinear(red); 31 | const g = rgbToLinear(green); 32 | const b = rgbToLinear(blue); 33 | 34 | return { 35 | x: (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100, 36 | y: (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100, 37 | z: (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100 38 | }; 39 | }; 40 | 41 | const xyzToLab = ({ x, y, z }) => { 42 | const refX = 95.047; 43 | const refY = 100.0; 44 | const refZ = 108.883; 45 | 46 | const fx = x / refX; 47 | const fy = y / refY; 48 | const fz = z / refZ; 49 | 50 | const transform = (t) => (t > 0.008856 ? Math.pow(t, 1 / 3) : (7.787 * t) + (16 / 116)); 51 | 52 | const xr = transform(fx); 53 | const yr = transform(fy); 54 | const zr = transform(fz); 55 | 56 | return { 57 | l: (116 * yr) - 16, 58 | a: 500 * (xr - yr), 59 | b: 200 * (yr - zr) 60 | }; 61 | }; 62 | 63 | const hexToLab = (hex) => { 64 | if (!hex) return null; 65 | const normalized = hex.replace("#", "").toLowerCase(); 66 | if (normalized.length !== 6) return null; 67 | const rgb = colorUtils.hexToRGB(normalized); 68 | return xyzToLab(rgbToXyz(rgb)); 69 | }; 70 | 71 | let cachedColorNames = null; 72 | 73 | const prepareColorNames = () => { 74 | if (cachedColorNames) return cachedColorNames; 75 | 76 | if (!Array.isArray(window.colorNameList)) { 77 | cachedColorNames = []; 78 | return cachedColorNames; 79 | } 80 | 81 | const normalizeDisplayName = (value) => { 82 | const trimmed = (value || "").trim(); 83 | if (!trimmed) return ""; 84 | return trimmed; 85 | }; 86 | 87 | cachedColorNames = window.colorNameList 88 | .map((item) => { 89 | const name = normalizeDisplayName(item.name); 90 | const slug = slugify(name); 91 | const hex = (item.hex || "").replace("#", "").toLowerCase(); 92 | const lab = hexToLab(hex); 93 | if (!slug || !hex || !lab) return null; 94 | return { 95 | slug, 96 | label: name || slug.replace(/-/g, " "), 97 | hex, 98 | lab 99 | }; 100 | }) 101 | .filter(item => item && item.slug && item.hex && item.lab); 102 | 103 | return cachedColorNames; 104 | }; 105 | 106 | const labDistance = (a, b) => { 107 | const dl = a.l - b.l; 108 | const da = a.a - b.a; 109 | const db = a.b - b.b; 110 | return dl * dl + da * da + db * db; 111 | }; 112 | 113 | const createFallbackName = (fallback) => { 114 | const raw = (typeof fallback === "string" ? fallback.trim() : "") || "color"; 115 | const slug = slugify(raw) || slugify("color"); 116 | return { 117 | slug, 118 | label: raw || slug 119 | }; 120 | }; 121 | 122 | const getFriendlyName = (hex, fallback) => { 123 | const names = prepareColorNames(); 124 | const targetLab = hexToLab(hex); 125 | if (!names.length || !targetLab) return createFallbackName(fallback); 126 | 127 | let closest = null; 128 | let minDistance = Infinity; 129 | 130 | for (const candidate of names) { 131 | const distance = labDistance(targetLab, candidate.lab); 132 | if (distance < minDistance) { 133 | minDistance = distance; 134 | closest = candidate; 135 | } 136 | } 137 | 138 | if (!closest) return createFallbackName(fallback); 139 | 140 | return { 141 | slug: closest.slug, 142 | label: closest.label || closest.slug 143 | }; 144 | }; 145 | 146 | const makeUniqueName = (name, usedNames) => { 147 | let finalName = name; 148 | let counter = 2; 149 | while (usedNames.has(finalName)) { 150 | finalName = `${name}-${counter}`; 151 | counter++; 152 | } 153 | usedNames.add(finalName); 154 | return finalName; 155 | }; 156 | 157 | window.exportNaming = { 158 | slugify, 159 | getFriendlyName, 160 | makeUniqueName, 161 | formatLabelForDisplay 162 | }; 163 | -------------------------------------------------------------------------------- /src/vendor/js/anchor.js: -------------------------------------------------------------------------------- 1 | // @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat 2 | // 3 | // AnchorJS - v5.0.0 - 2023-01-18 4 | // https://www.bryanbraun.com/anchorjs/ 5 | // Copyright (c) 2023 Bryan Braun; Licensed MIT 6 | // 7 | // @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat 8 | !function(A,e){"use strict";"function"==typeof define&&define.amd?define([],e):"object"==typeof module&&module.exports?module.exports=e():(A.AnchorJS=e(),A.anchors=new A.AnchorJS)}(globalThis,function(){"use strict";return function(A){function u(A){A.icon=Object.prototype.hasOwnProperty.call(A,"icon")?A.icon:"",A.visible=Object.prototype.hasOwnProperty.call(A,"visible")?A.visible:"hover",A.placement=Object.prototype.hasOwnProperty.call(A,"placement")?A.placement:"right",A.ariaLabel=Object.prototype.hasOwnProperty.call(A,"ariaLabel")?A.ariaLabel:"Anchor",A.class=Object.prototype.hasOwnProperty.call(A,"class")?A.class:"",A.base=Object.prototype.hasOwnProperty.call(A,"base")?A.base:"",A.truncate=Object.prototype.hasOwnProperty.call(A,"truncate")?Math.floor(A.truncate):64,A.titleText=Object.prototype.hasOwnProperty.call(A,"titleText")?A.titleText:""}function d(A){var e;if("string"==typeof A||A instanceof String)e=[].slice.call(document.querySelectorAll(A));else{if(!(Array.isArray(A)||A instanceof NodeList))throw new TypeError("The selector provided to AnchorJS was invalid.");e=[].slice.call(A)}return e}this.options=A||{},this.elements=[],u(this.options),this.add=function(A){var e,t,o,i,n,s,a,r,l,c,h,p=[];if(u(this.options),0!==(e=d(A=A||"h2, h3, h4, h5, h6")).length){for(null===document.head.querySelector("style.anchorjs")&&((A=document.createElement("style")).className="anchorjs",A.appendChild(document.createTextNode("")),void 0===(h=document.head.querySelector('[rel="stylesheet"],style'))?document.head.appendChild(A):document.head.insertBefore(A,h),A.sheet.insertRule(".anchorjs-link{opacity:0;text-decoration:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}",A.sheet.cssRules.length),A.sheet.insertRule(":hover>.anchorjs-link,.anchorjs-link:focus{opacity:1}",A.sheet.cssRules.length),A.sheet.insertRule("[data-anchorjs-icon]::after{content:attr(data-anchorjs-icon)}",A.sheet.cssRules.length),A.sheet.insertRule('@font-face{font-family:anchorjs-icons;src:url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype")}',A.sheet.cssRules.length)),h=document.querySelectorAll("[id]"),t=[].map.call(h,function(A){return A.id}),i=0;i\]./()*\\\n\t\b\v\u00A0]/g,"-").replace(/-{2,}/g,"-").substring(0,this.options.truncate).replace(/^-+|-+$/gm,"").toLowerCase()},this.hasAnchorJSLink=function(A){var e=A.firstChild&&-1<(" "+A.firstChild.className+" ").indexOf(" anchorjs-link "),A=A.lastChild&&-1<(" "+A.lastChild.className+" ").indexOf(" anchorjs-link ");return e||A||!1}}}); 9 | // @license-end -------------------------------------------------------------------------------- /src/styles/_dark.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as *; 2 | 3 | html.darkmode-active { 4 | background-color: $gray-600; 5 | color-scheme: dark; 6 | 7 | body { 8 | color: $gray-100; 9 | background-color: $gray-600; 10 | 11 | a { 12 | color: $gray-100; 13 | border-color: $gray-100; 14 | } 15 | 16 | .header { 17 | .preface { 18 | .theme-toggle { 19 | background-color: $black-transparent; 20 | 21 | &:hover { 22 | background-color: $black; 23 | } 24 | 25 | &:active { 26 | background-color: color-mix(in srgb, $black 85%, $white); 27 | } 28 | 29 | &-icon { 30 | 31 | svg { 32 | color: $white; 33 | } 34 | 35 | .icon-tabler-sun-high { 36 | display: inline-block; 37 | } 38 | 39 | .icon-tabler-moon { 40 | display: none; 41 | } 42 | } 43 | } 44 | 45 | .announcement { 46 | background-color: $black-transparent; 47 | color: $gray-100; 48 | border-color: $black-transparent; 49 | 50 | &:hover { 51 | background-color: $black; 52 | } 53 | 54 | &:active { 55 | background-color: color-mix(in srgb, $black 85%, $white); 56 | } 57 | } 58 | 59 | a.github-corner { 60 | fill: $black-transparent; 61 | } 62 | } 63 | 64 | .title a { 65 | color: $gray-50; 66 | } 67 | } 68 | 69 | .form { 70 | textarea { 71 | background-color: $gray-500; 72 | border-color: $gray-500; 73 | color: $gray-100; 74 | } 75 | 76 | .color-picker-button { 77 | background-color: $black-transparent; 78 | color: $gray-100; 79 | } 80 | } 81 | 82 | [data-tooltip]::after { 83 | background-color: $gray-500; 84 | color: $white; 85 | } 86 | 87 | #clr-picker { 88 | background-color: $black; 89 | 90 | input.clr-color { 91 | background-color: $gray-500; 92 | border-color: $gray-500; 93 | } 94 | } 95 | 96 | .palettes { 97 | .palette-controls { 98 | .step-selector-option { 99 | background-color: $black-transparent; 100 | color: $gray-100; 101 | border: none; 102 | 103 | &.is-active { 104 | box-shadow: inset 0 0 0 2px $gray-500; 105 | opacity: 1; 106 | background-color: $black; 107 | 108 | &:active { 109 | background-color: color-mix(in srgb, $black 85%, $white); 110 | } 111 | } 112 | 113 | &:hover { 114 | background-color: $black; 115 | } 116 | 117 | &:active { 118 | background-color: color-mix(in srgb, $black 85%, $white); 119 | } 120 | } 121 | 122 | .action-button { 123 | background-color: $black-transparent; 124 | color: $gray-100; 125 | 126 | &:hover { 127 | background-color: $black; 128 | } 129 | 130 | &:active { 131 | background-color: color-mix(in srgb, $black 85%, $white); 132 | } 133 | 134 | &.is-active { 135 | border-color: $gray-400; 136 | box-shadow: inset 0 0 0 2px $gray-500; 137 | background-color: $black; 138 | 139 | &:active { 140 | background-color: color-mix(in srgb, $black 85%, $white); 141 | } 142 | } 143 | } 144 | } 145 | 146 | #tints-and-shades { 147 | .palette-titlebar { 148 | 149 | &-name { 150 | color: $gray-100; 151 | } 152 | 153 | &-action { 154 | color: $gray-100; 155 | } 156 | } 157 | 158 | .palette-complement-dropdown { 159 | &-menu { 160 | background-color: $black; 161 | } 162 | 163 | &-item { 164 | color: $gray-100; 165 | 166 | &:is(:hover, :focus-visible) { 167 | background-color: $gray-600; 168 | color: $white; 169 | } 170 | 171 | &:active { 172 | background-color: color-mix(in srgb, $gray-600 92%, $white); 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | .utility-dialog { 180 | background-color: $gray-600; 181 | color: $gray-100; 182 | border-color: $gray-600; 183 | 184 | &-close { 185 | color: $gray-100; 186 | } 187 | 188 | &.export-dialog { 189 | .export-tab { 190 | color: $gray-300; 191 | 192 | &:hover { 193 | color: $gray-100; 194 | } 195 | 196 | &.is-active { 197 | border-color: transparent; 198 | color: $gray-100; 199 | background-color: $black-transparent; 200 | } 201 | } 202 | 203 | .export-body { 204 | .export-output { 205 | border-color: transparent; 206 | background-color: $black-transparent; 207 | 208 | &-code { 209 | color: $gray-100; 210 | } 211 | } 212 | } 213 | } 214 | 215 | &.share-dialog { 216 | .share-input { 217 | background-color: $black-transparent; 218 | color: $gray-100; 219 | } 220 | } 221 | } 222 | 223 | .docs { 224 | code { 225 | background-color: $gray-500; 226 | } 227 | } 228 | 229 | .not-found { 230 | background-color: $black-transparent; 231 | color: $gray-100; 232 | } 233 | } 234 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [](https://maketintsandshades.com)  [Tint & Shade Generator](https://maketintsandshades.com) 2 | 3 | 4 | 5 | 6 | 7 | Screenshot of app home page 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Screenshot of app home page 16 | 17 | 18 | 19 | ## What is the Tint & Shade Generator? 20 | 21 | The Tint & Shade Generator is a precision color tool for producing accurate tints (pure white added) and shades (pure black added) from a given hex color in 5%, 10%, or 20% increments. 22 | 23 | ## Why is this tool unique? 24 | 25 | It takes the math seriously. In my experience similar tools get the calculations incorrect due to rounding errors, creator preferences, or other inconsistencies. 26 | 27 | Testing shows that the output matches Chrome DevTools’ calculation method as well as some [established](https://css-tricks.com/snippets/sass/tint-shade-functions), [popular](https://sindresorhus.com/sass-extras/#color-function-tint) methods to derive tints and shades via Sass. 28 | 29 | ## When would I use this? 30 | 31 | Originally created for designer and developer teams, it’s also useful for teachers, data pros, brand strategists, presentation makers, and anyone who works with colors. 32 | 33 | It’s perfect for: 34 | 35 | - exploring and refining colors visually 36 | - moving from a single color to a complete system 37 | - generating consistent tints and shades for UI states 38 | - building complementary palettes for accents or secondary UI 39 | - sharing palettes via link or image 40 | - exporting colors for design tokens, CSS, or JSON 41 | 42 | ## Calculation method 43 | 44 | The given hex color is first converted to RGB. Each RGB component is then calculated independently as follows: 45 | 46 | - **Tints:** `New value = current value + ((255 - current value) x tint factor)` 47 | - **Shades:** `New value = current value x shade factor` 48 | 49 | The new value is rounded to the nearest whole number (values ending in .5 round up), then converted back to hex for display. 50 | 51 | ## Example calculation 52 | 53 | Let’s say we want tints and shades of [Rebecca Purple](https://meyerweb.com/eric/thoughts/2014/06/19/rebeccapurple/), #663399. 54 | 55 | ### 10% tint 56 | 57 | 1. #663399 is converted to the RGB equivalent of 102, 51, 153 58 | 2. **R:** `102 + ((255 - 102) x .1) = 117.3`, rounded to 117 59 | 3. **G:** `51 + ((255 - 51) x .1) = 71.4`, rounded to 71 60 | 4. **B:** `153 + ((255 - 153) x .1) = 163.2`, rounded to 163 61 | 5. RGB 117, 71, 163 is converted to the hex equivalent of #7547a3 62 | 63 | ### 10% shade 64 | 65 | 1. #663399 is converted to the RGB equivalent of 102, 51, 153 66 | 2. **R:** `102 x .9 = 91.8`, rounded to 92 67 | 3. **G:** `51 x .9 = 45.9`, rounded to 46 68 | 4. **B:** `153 x .9 = 137.7`, rounded to 138 69 | 5. RGB 92, 46, 138 is converted to the hex equivalent of #5c2e8a 70 | 71 | ## Related colors 72 | 73 | In addition to generating tints and shades, you can also add related palettes based on common color-wheel relationships. These palettes shift the hue while preserving the original saturation and lightness, which is well suited for most color systems. 74 | 75 | ### Complementary 76 | 77 | Adds one new palette using the hue directly opposite the base color (180°). This produces the strongest contrast and is best when clear visual separation is needed. 78 | 79 | ### Split complementary 80 | 81 | Adds two palettes using hues 30° on either side of the complementary color. This keeps contrast high but less extreme than a direct complementary pairing. 82 | 83 | ### Analogous 84 | 85 | Adds two palettes using hues 30° on either side of the base color. These combinations are low-contrast and cohesive, making them appropriate for subtle variation. 86 | 87 | ### Triadic 88 | 89 | Adds two palettes evenly spaced at 120° intervals around the color wheel. This produces clearly distinct and energetic color relationships. 90 | 91 | ## Figma plugin 92 | 93 | Now you can generate the same meticulously-crafted tints and shades without leaving your canvas (and automatically create local color styles, too). Grab the plugin [from the Figma Community](https://www.figma.com/community/plugin/1580658889126377365/tint-shade-generator). 94 | 95 | ## Feedback and contributing 96 | 97 | This project is open source and I’d love your help! 98 | 99 | If you notice a bug or want a feature added please [file an issue on GitHub](https://github.com/edelstone/tints-and-shades/issues/new). If you don’t have an account there, just [email me](mailto:contact@maketintsandshades.com) the details. 100 | 101 | If you’re a developer and want to help with the project, please comment on [open issues](https://github.com/edelstone/tints-and-shades/issues) or create a new one and communicate your intentions. Once we agree on a path forward you can just make a pull request and take it to the finish line. 102 | 103 | ## Local development 104 | 105 | _Prerequisites: Node.js 18+_ 106 | 107 | 1. Clone this project. 108 | 2. Navigate to the project in your terminal. 109 | 3. Install dependencies: `npm install`. 110 | 4. Start the server: `npm run start`. 111 | 5. Navigate to `localhost:8080` in your browser. 112 | 113 | ## Support this project 114 | 115 | The Tint & Shade Generator will always be free but your support is greatly appreciated. 116 | 117 | - [Buy Me a Coffee](https://www.buymeacoffee.com/edelstone) 118 | - [Cash App](https://cash.app/$edelstone) 119 | - [Paypal](https://www.paypal.me/edelstone) 120 | - [Venmo](https://venmo.com/michaeledelstone) 121 | 122 | ## Credits 123 | 124 | [Michael Edelstone](https://michaeledelstone.com) designed and organized the project with major assistance from [Nick Wing](https://github.com/wickning1) on the color calculations. 125 | 126 | We use these amazing open-source libraries across the project: 127 | 128 | - [AnchorJS](https://github.com/bryanbraun/anchorjs) 129 | - [clipboard.js](https://github.com/zenorocha/clipboard.js) 130 | - [Color Names](https://github.com/meodai/color-names) 131 | - [Eleventy](https://github.com/11ty/eleventy) 132 | - [Prism](https://github.com/PrismJS/prism) 133 | 134 | Many thanks to [Joel Carr](https://github.com/joelcarr), [Sebastian Gutierrez](https://github.com/pepas24), [Tim Scalzo](https://github.com/TJScalzo), [Aman Agarwal](https://github.com/AmanAgarwal041), [Aleksandr Hovhannisyan](https://github.com/AleksandrHovhannisyan), [Shubhendu Sen](https://github.com/Sen-442b), and [Luis Escarrilla](https://github.com/latesc) for their valuable contributions. 135 | 136 | ## Design specs 137 | 138 | - Typography: [Work Sans](https://weiweihuanghuang.github.io/Work-Sans/) by Wei Huang 139 | - Iconography: [Tabler Icons](https://tabler.io/icons) 140 | - Colors: [#000000](/#colors=000000), [#ffffff](/#colors=ffffff), [#e96443](/#colors=e96443), and [#ca228e](/#colors=ca228e) 141 | 142 | Prefer Google’s color logic? Try the [Material Design Palette Generator](https://materialpalettes.com). 143 | -------------------------------------------------------------------------------- /src/about.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/base 3 | title: About 4 | permalink: /about/ 5 | docs: true 6 | --- 7 | 8 | 9 |

What is the Tint & Shade Generator?

10 | 11 |

The Tint & Shade Generator is a precision color tool for producing accurate tints (pure white added) and shades (pure black added) from a given hex color in 5%, 10%, or 20% increments.

12 | 13 |

Why is this tool unique?

14 | 15 |

It takes the math seriously. In my experience similar tools get the calculations incorrect due to rounding errors, creator preferences, or other inconsistencies.

16 | 17 |

Testing shows that the output matches Chrome DevTools’ calculation method as well as some established, popular methods to derive tints and shades via Sass.

18 | 19 |

When would I use this?

20 | 21 |

Originally created for designer and developer teams, it’s also useful for teachers, data pros, brand strategists, presentation makers, and anyone who works with colors.

22 | 23 |

It’s perfect for:

24 | 25 | 33 | 34 |

Calculation method

35 | 36 |

The given hex color is first converted to RGB. Each RGB component is then calculated independently as follows:

37 | 38 | 42 | 43 |

The new value is rounded to the nearest whole number (values ending in .5 round up), then converted back to hex for display.

44 | 45 |

Example calculation

46 | 47 |

Let’s say we want tints and shades of Rebecca Purple, #663399.

48 | 49 |

10% tint

50 | 51 |
    52 |
  1. #663399 is converted to the RGB equivalent of 102, 51, 153
  2. 53 |
  3. R: 102 + ((255 - 102) x .1) = 117.3, rounded to 117
  4. 54 |
  5. G: 51 + ((255 - 51) x .1) = 71.4, rounded to 71
  6. 55 |
  7. B: 153 + ((255 - 153) x .1) = 163.2, rounded to 163
  8. 56 |
  9. RGB 117, 71, 163 is converted to the hex equivalent of #7547a3
  10. 57 |
58 | 59 |

10% shade

60 | 61 |
    62 |
  1. #663399 is converted to the RGB equivalent of 102, 51, 153
  2. 63 |
  3. R: 102 x .9 = 91.8, rounded to 92
  4. 64 |
  5. G: 51 x .9 = 45.9, rounded to 46
  6. 65 |
  7. B: 153 x .9 = 137.7, rounded to 138
  8. 66 |
  9. RGB 92, 46, 138 is converted to the hex equivalent of #5c2e8a
  10. 67 |
68 | 69 |

Related colors

70 | 71 |

In addition to generating tints and shades, you can also add related palettes based on common color-wheel relationships. These palettes shift the hue while preserving the original saturation and lightness, which is well suited for most color systems.

72 | 73 |

Complementary

74 | 75 |

Adds one new palette using the hue directly opposite the base color (180°). This produces the strongest contrast and is best when clear visual separation is needed.

76 | 77 |

Split complementary

78 | 79 |

Adds two palettes using hues 30° on either side of the complementary color. This keeps contrast high but less extreme than a direct complementary pairing.

80 | 81 |

Analogous

82 | 83 |

Adds two palettes using hues 30° on either side of the base color. These combinations are low-contrast and cohesive, making them appropriate for subtle variation.

84 | 85 |

Triadic

86 | 87 |

Adds two palettes evenly spaced at 120° intervals around the color wheel. This produces clearly distinct and energetic color relationships.

88 | 89 |

{% include "partials/icons/figma.njk" %} Figma plugin

90 | 91 |

Now you can generate the same meticulously-crafted tints and shades without leaving your canvas (and automatically create local color styles, too). Grab the plugin from the Figma Community.

92 | 93 |

Feedback and contributing

94 | 95 |

This project is open source and I’d love your help!

96 | 97 |

If you notice a bug or want a feature added please file an issue on GitHub. If you don’t have an account there, just email me the details.

98 | 99 |

If you’re a developer and want to help with the project, please comment on open issues or create a new one and communicate your intentions. Once we agree on a path forward you can just make a pull request and take it to the finish line.

100 | 101 | 102 | 103 |

Support this project

104 | 105 |

The Tint & Shade Generator will always be free but your support is greatly appreciated.

106 | 107 | 113 | 114 |

Credits

115 | 116 |

Michael Edelstone designed and organized the project with major assistance from Nick Wing on the color calculations.

117 | 118 |

We use these amazing open-source libraries across the project:

119 | 120 | 127 | 128 |

Many thanks to Joel Carr, Sebastian Gutierrez, Tim Scalzo, Aman Agarwal, Aleksandr Hovhannisyan, Shubhendu Sen, and Luis Escarrilla for their valuable contributions.

129 | 130 |

Design specs

131 | 132 | 137 | 138 |

Prefer Google’s color logic? Try the Material Design Palette Generator.

139 | 140 | -------------------------------------------------------------------------------- /src/vendor/css/coloris.min.css: -------------------------------------------------------------------------------- 1 | .clr-picker{display:none;flex-wrap:wrap;position:absolute;width:200px;z-index:1000;border-radius:10px;background-color:#fff;justify-content:flex-end;direction:ltr;box-shadow:0 0 5px rgba(0,0,0,.05),0 5px 20px rgba(0,0,0,.1);-moz-user-select:none;-webkit-user-select:none;user-select:none}.clr-picker.clr-open,.clr-picker[data-inline=true]{display:flex}.clr-picker[data-inline=true]{position:relative}.clr-gradient{position:relative;width:100%;height:100px;margin-bottom:15px;border-radius:3px 3px 0 0;background-image:linear-gradient(rgba(0,0,0,0),#000),linear-gradient(90deg,#fff,currentColor);cursor:pointer}.clr-marker{position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border:1px solid #fff;border-radius:50%;background-color:currentColor;cursor:pointer}.clr-picker input[type=range]::-webkit-slider-runnable-track{width:100%;height:16px}.clr-picker input[type=range]::-webkit-slider-thumb{width:16px;height:16px;-webkit-appearance:none}.clr-picker input[type=range]::-moz-range-track{width:100%;height:16px;border:0}.clr-picker input[type=range]::-moz-range-thumb{width:16px;height:16px;border:0}.clr-hue{background-image:linear-gradient(to right,red 0,#ff0 16.66%,#0f0 33.33%,#0ff 50%,#00f 66.66%,#f0f 83.33%,red 100%)}.clr-alpha,.clr-hue{position:relative;width:calc(100% - 40px);height:8px;margin:5px 20px;border-radius:4px}.clr-alpha span{display:block;height:100%;width:100%;border-radius:inherit;background-image:linear-gradient(90deg,rgba(0,0,0,0),currentColor)}.clr-alpha input[type=range],.clr-hue input[type=range]{position:absolute;width:calc(100% + 32px);height:16px;left:-16px;top:-4px;margin:0;background-color:transparent;opacity:0;cursor:pointer;appearance:none;-webkit-appearance:none}.clr-alpha div,.clr-hue div{position:absolute;width:16px;height:16px;left:0;top:50%;margin-left:-8px;transform:translateY(-50%);border:2px solid #fff;border-radius:50%;background-color:currentColor;box-shadow:0 0 1px #888;pointer-events:none}.clr-alpha div:before{content:'';position:absolute;height:100%;width:100%;left:0;top:0;border-radius:50%;background-color:currentColor}.clr-format{display:none;order:1;width:calc(100% - 40px);margin:0 20px 20px}.clr-segmented{display:flex;position:relative;width:100%;margin:0;padding:0;border:1px solid #ddd;border-radius:15px;box-sizing:border-box;color:#999;font-size:12px}.clr-segmented input,.clr-segmented legend{position:absolute;width:100%;height:100%;margin:0;padding:0;border:0;left:0;top:0;opacity:0;pointer-events:none}.clr-segmented label{flex-grow:1;margin:0;padding:4px 0;font-size:inherit;font-weight:400;line-height:initial;text-align:center;cursor:pointer}.clr-segmented label:first-of-type{border-radius:10px 0 0 10px}.clr-segmented label:last-of-type{border-radius:0 10px 10px 0}.clr-segmented input:checked+label{color:#fff;background-color:#666}.clr-swatches{order:2;width:calc(100% - 32px);margin:0 16px}.clr-swatches div{display:flex;flex-wrap:wrap;padding-bottom:12px;justify-content:center}.clr-swatches button{position:relative;width:20px;height:20px;margin:0 4px 6px 4px;padding:0;border:0;border-radius:50%;color:inherit;text-indent:-1000px;white-space:nowrap;overflow:hidden;cursor:pointer}.clr-swatches button:after{content:'';display:block;position:absolute;width:100%;height:100%;left:0;top:0;border-radius:inherit;background-color:currentColor;box-shadow:inset 0 0 0 1px rgba(0,0,0,.1)}input.clr-color{order:1;width:calc(100% - 80px);height:32px;margin:15px 20px 20px auto;padding:0 10px;border:1px solid #ddd;border-radius:16px;color:#444;background-color:#fff;font-family:sans-serif;font-size:14px;text-align:center;box-shadow:none}input.clr-color:focus{outline:0;border:1px solid #1e90ff}.clr-clear,.clr-close{display:none;order:2;height:24px;margin:0 20px 20px;padding:0 20px;border:0;border-radius:12px;color:#fff;background-color:#666;font-family:inherit;font-size:12px;font-weight:400;cursor:pointer}.clr-close{display:block;margin:0 20px 20px auto}.clr-preview{position:relative;width:32px;height:32px;margin:15px 0 20px 20px;border-radius:50%;overflow:hidden}.clr-preview:after,.clr-preview:before{content:'';position:absolute;height:100%;width:100%;left:0;top:0;border:1px solid #fff;border-radius:50%}.clr-preview:after{border:0;background-color:currentColor;box-shadow:inset 0 0 0 1px rgba(0,0,0,.1)}.clr-preview button{position:absolute;width:100%;height:100%;z-index:1;margin:0;padding:0;border:0;border-radius:50%;outline-offset:-2px;background-color:transparent;text-indent:-9999px;cursor:pointer;overflow:hidden}.clr-alpha div,.clr-color,.clr-hue div,.clr-marker{box-sizing:border-box}.clr-field{display:inline-block;position:relative;color:transparent}.clr-field input{margin:0;direction:ltr}.clr-field.clr-rtl input{text-align:right}.clr-field button{position:absolute;width:30px;height:100%;right:0;top:50%;transform:translateY(-50%);margin:0;padding:0;border:0;color:inherit;text-indent:-1000px;white-space:nowrap;overflow:hidden;pointer-events:none}.clr-field.clr-rtl button{right:auto;left:0}.clr-field button:after{content:'';display:block;position:absolute;width:100%;height:100%;left:0;top:0;border-radius:inherit;background-color:currentColor;box-shadow:inset 0 0 1px rgba(0,0,0,.5)}.clr-alpha,.clr-alpha div,.clr-field button,.clr-preview:before,.clr-swatches button{background-image:repeating-linear-gradient(45deg,#aaa 25%,transparent 25%,transparent 75%,#aaa 75%,#aaa),repeating-linear-gradient(45deg,#aaa 25%,#fff 25%,#fff 75%,#aaa 75%,#aaa);background-position:0 0,4px 4px;background-size:8px 8px}.clr-marker:focus{outline:0}.clr-keyboard-nav .clr-alpha input:focus+div,.clr-keyboard-nav .clr-hue input:focus+div,.clr-keyboard-nav .clr-marker:focus,.clr-keyboard-nav .clr-segmented input:focus+label{outline:0;box-shadow:0 0 0 2px #1e90ff,0 0 2px 2px #fff}.clr-picker[data-alpha=false] .clr-alpha{display:none}.clr-picker[data-minimal=true]{padding-top:16px}.clr-picker[data-minimal=true] .clr-alpha,.clr-picker[data-minimal=true] .clr-color,.clr-picker[data-minimal=true] .clr-gradient,.clr-picker[data-minimal=true] .clr-hue,.clr-picker[data-minimal=true] .clr-preview{display:none}.clr-dark{background-color:#444}.clr-dark .clr-segmented{border-color:#777}.clr-dark .clr-swatches button:after{box-shadow:inset 0 0 0 1px rgba(255,255,255,.3)}.clr-dark input.clr-color{color:#fff;border-color:#777;background-color:#555}.clr-dark input.clr-color:focus{border-color:#1e90ff}.clr-dark .clr-preview:after{box-shadow:inset 0 0 0 1px rgba(255,255,255,.5)}.clr-dark .clr-alpha,.clr-dark .clr-alpha div,.clr-dark .clr-preview:before,.clr-dark .clr-swatches button{background-image:repeating-linear-gradient(45deg,#666 25%,transparent 25%,transparent 75%,#888 75%,#888),repeating-linear-gradient(45deg,#888 25%,#444 25%,#444 75%,#888 75%,#888)}.clr-picker.clr-polaroid{border-radius:6px;box-shadow:0 0 5px rgba(0,0,0,.1),0 5px 30px rgba(0,0,0,.2)}.clr-picker.clr-polaroid:before{content:'';display:block;position:absolute;width:16px;height:10px;left:20px;top:-10px;border:solid transparent;border-width:0 8px 10px 8px;border-bottom-color:currentColor;box-sizing:border-box;color:#fff;filter:drop-shadow(0 -4px 3px rgba(0,0,0,.1));pointer-events:none}.clr-picker.clr-polaroid.clr-dark:before{color:#444}.clr-picker.clr-polaroid.clr-left:before{left:auto;right:20px}.clr-picker.clr-polaroid.clr-top:before{top:auto;bottom:-10px;transform:rotateZ(180deg)}.clr-polaroid .clr-gradient{width:calc(100% - 20px);height:120px;margin:10px;border-radius:3px}.clr-polaroid .clr-alpha,.clr-polaroid .clr-hue{width:calc(100% - 30px);height:10px;margin:6px 15px;border-radius:5px}.clr-polaroid .clr-alpha div,.clr-polaroid .clr-hue div{box-shadow:0 0 5px rgba(0,0,0,.2)}.clr-polaroid .clr-format{width:calc(100% - 20px);margin:0 10px 15px}.clr-polaroid .clr-swatches{width:calc(100% - 12px);margin:0 6px}.clr-polaroid .clr-swatches div{padding-bottom:10px}.clr-polaroid .clr-swatches button{width:22px;height:22px}.clr-polaroid input.clr-color{width:calc(100% - 60px);margin:10px 10px 15px auto}.clr-polaroid .clr-clear{margin:0 10px 15px 10px}.clr-polaroid .clr-close{margin:0 10px 15px auto}.clr-polaroid .clr-preview{margin:10px 0 15px 10px}.clr-picker.clr-large{width:275px}.clr-large .clr-gradient{height:150px}.clr-large .clr-swatches button{width:22px;height:22px}.clr-picker.clr-pill{width:380px;padding-left:180px;box-sizing:border-box}.clr-pill .clr-gradient{position:absolute;width:180px;height:100%;left:0;top:0;margin-bottom:0;border-radius:3px 0 0 3px}.clr-pill .clr-hue{margin-top:20px} -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | const SETTINGS_STORAGE_KEY = "settings"; 2 | const settings = { copyWithHashtag: false, tintShadeCount: 10 }; 3 | const tintShadeOptions = [5, 10, 20]; 4 | 5 | const setActiveCountButtons = (buttons, count) => { 6 | buttons.forEach((btn) => { 7 | const btnValue = parseInt(btn.getAttribute("data-count"), 10); 8 | const isActive = btnValue === count; 9 | btn.classList.toggle("is-active", isActive); 10 | btn.setAttribute("aria-pressed", isActive ? "true" : "false"); 11 | btn.setAttribute("tabindex", isActive ? "0" : "-1"); 12 | }); 13 | }; 14 | const TOOLTIP_IMMEDIATE_ATTR = "data-tooltip-immediate"; 15 | let lastInteractionWasKeyboard = false; 16 | 17 | const wireTooltipHandlers = () => { 18 | document.addEventListener( 19 | "pointerdown", 20 | () => { 21 | lastInteractionWasKeyboard = false; 22 | }, 23 | true 24 | ); 25 | 26 | document.addEventListener( 27 | "keydown", 28 | (event) => { 29 | if (event.key === "Tab") { 30 | lastInteractionWasKeyboard = true; 31 | } 32 | }, 33 | true 34 | ); 35 | 36 | document.addEventListener( 37 | "focusout", 38 | (event) => { 39 | const target = event.target?.closest?.("[data-tooltip]"); 40 | if (target && lastInteractionWasKeyboard) { 41 | target.setAttribute(TOOLTIP_IMMEDIATE_ATTR, "true"); 42 | } 43 | }, 44 | true 45 | ); 46 | 47 | document.addEventListener( 48 | "focusin", 49 | (event) => { 50 | const target = event.target?.closest?.("[data-tooltip]"); 51 | if (target) { 52 | target.removeAttribute(TOOLTIP_IMMEDIATE_ATTR); 53 | } 54 | }, 55 | true 56 | ); 57 | }; 58 | 59 | const loadSettings = () => { 60 | try { 61 | const savedSettings = localStorage.getItem(SETTINGS_STORAGE_KEY); 62 | if (!savedSettings) return; 63 | const parsed = JSON.parse(savedSettings); 64 | if (parsed && typeof parsed === "object") { 65 | Object.assign(settings, parsed); 66 | } 67 | } catch (e) { 68 | // ignore bad or unavailable storage 69 | } 70 | }; 71 | 72 | const saveSettings = () => { 73 | try { 74 | localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); 75 | } catch (e) { 76 | // ignore 77 | } 78 | }; 79 | 80 | const suppressTooltipUntilMouseOut = (target) => { 81 | if (!target || !target.setAttribute) return; 82 | target.setAttribute("data-tooltip-suppressed", "true"); 83 | const clear = () => { 84 | target.removeAttribute("data-tooltip-suppressed"); 85 | }; 86 | target.addEventListener("mouseleave", clear, { once: true }); 87 | target.addEventListener("pointerleave", clear, { once: true }); 88 | }; 89 | 90 | const updateHashtagToggle = (button, isOn) => { 91 | if (!button) return; 92 | button.setAttribute("aria-pressed", isOn ? "true" : "false"); 93 | button.classList.toggle("is-active", isOn); 94 | button.setAttribute("aria-label", isOn ? "Include hashtag when copying" : "Hide hashtag when copying"); 95 | button.setAttribute("data-tooltip", isOn ? "Hide #" : "Show #"); 96 | }; 97 | 98 | // Initialize the settings and UI state 99 | const initializeSettings = (initialUrlState = {}) => { 100 | loadSettings(); 101 | if (typeof initialUrlState.copyWithHashtag === "boolean") { 102 | settings.copyWithHashtag = initialUrlState.copyWithHashtag; 103 | } 104 | if (typeof initialUrlState.tintShadeCount === "number") { 105 | settings.tintShadeCount = palettes.normalizeTintShadeCount(initialUrlState.tintShadeCount); 106 | } 107 | 108 | const colorValuesElement = document.getElementById("color-values"); 109 | const hashtagToggle = document.getElementById("show-hide-hashtags"); 110 | const stepSelector = document.querySelector(".inline-actions .step-selector"); 111 | const tintShadeButtons = stepSelector ? Array.from(stepSelector.querySelectorAll(".step-selector-option")) : []; 112 | 113 | if (hashtagToggle) { 114 | updateHashtagToggle(hashtagToggle, settings.copyWithHashtag); 115 | hashtagToggle.addEventListener("pointerdown", () => { 116 | suppressTooltipUntilMouseOut(hashtagToggle); 117 | }); 118 | hashtagToggle.addEventListener("click", () => { 119 | settings.copyWithHashtag = !settings.copyWithHashtag; 120 | updateHashtagToggle(hashtagToggle, settings.copyWithHashtag); 121 | saveSettings(); 122 | exportUI.updateClipboardData(settings.copyWithHashtag); 123 | exportUI.updateExportOutput(exportUI.state, exportUI.elements); 124 | if (palettes.updateHexValueDisplay) { 125 | palettes.updateHexValueDisplay(settings.copyWithHashtag); 126 | } 127 | if (palettes.updateHashState && palettes.parseColorValues && colorValuesElement) { 128 | const parsedColors = palettes.parseColorValues(colorValuesElement.value) || []; 129 | if (!parsedColors.length) return; 130 | palettes.updateHashState(parsedColors, settings); 131 | } 132 | }); 133 | } 134 | 135 | if (tintShadeButtons.length) { 136 | if (!tintShadeOptions.includes(settings.tintShadeCount)) { 137 | settings.tintShadeCount = 10; 138 | } 139 | setActiveCountButtons(tintShadeButtons, settings.tintShadeCount); 140 | 141 | const activateIndex = (nextIndex) => { 142 | const target = tintShadeButtons[nextIndex]; 143 | if (!target) return; 144 | const nextValue = parseInt(target.getAttribute("data-count"), 10); 145 | if (!tintShadeOptions.includes(nextValue)) return; 146 | if (settings.tintShadeCount === nextValue) { 147 | target.focus(); 148 | return; 149 | } 150 | settings.tintShadeCount = nextValue; 151 | setActiveCountButtons(tintShadeButtons, settings.tintShadeCount); 152 | saveSettings(); 153 | palettes.createTintsAndShades(settings, false, { skipScroll: true, skipFocus: true }); 154 | target.focus(); 155 | }; 156 | 157 | tintShadeButtons.forEach((button, index) => { 158 | button.addEventListener("click", () => { 159 | activateIndex(index); 160 | }); 161 | 162 | button.addEventListener("keydown", (event) => { 163 | if (event.key === "ArrowRight" || event.key === "ArrowDown") { 164 | event.preventDefault(); 165 | const nextIndex = (index + 1) % tintShadeButtons.length; 166 | activateIndex(nextIndex); 167 | } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { 168 | event.preventDefault(); 169 | const prevIndex = (index - 1 + tintShadeButtons.length) % tintShadeButtons.length; 170 | activateIndex(prevIndex); 171 | } else if (event.key === "Home") { 172 | event.preventDefault(); 173 | activateIndex(0); 174 | } else if (event.key === "End") { 175 | event.preventDefault(); 176 | activateIndex(tintShadeButtons.length - 1); 177 | } else if (event.key === "Enter" || event.key === " ") { 178 | event.preventDefault(); 179 | activateIndex(index); 180 | } 181 | }); 182 | }); 183 | } 184 | }; 185 | 186 | document.addEventListener("DOMContentLoaded", () => { 187 | wireTooltipHandlers(); 188 | const urlState = palettes.readHashState ? palettes.readHashState() : {}; 189 | initializeSettings(urlState); 190 | exportUI.wireExportControls(); 191 | 192 | const colorValuesElement = document.getElementById("color-values"); 193 | if (colorValuesElement) { 194 | colorValuesElement.value = urlState.colors || ""; 195 | } else { 196 | console.error("Element with id 'color-values' not found."); 197 | } 198 | 199 | palettes.createTintsAndShades(settings, true); 200 | 201 | const colorEntryForm = document.getElementById("color-entry-form"); 202 | if (colorEntryForm) { 203 | colorEntryForm.addEventListener("submit", (e) => { 204 | e.preventDefault(); 205 | const { 206 | skipScroll = false, 207 | skipFocus = false, 208 | focusPickerContext = null 209 | } = e.detail || {}; 210 | palettes.createTintsAndShades(settings, false, { skipScroll, skipFocus, focusPickerContext }); 211 | }); 212 | } else { 213 | console.error("Element with id 'color-entry-form' not found."); 214 | } 215 | 216 | const copyWithHashtagToggle = document.getElementById("show-hide-hashtags"); 217 | if (!copyWithHashtagToggle) { 218 | console.error("Element with id 'show-hide-hashtags' not found."); 219 | } 220 | }); 221 | 222 | document.addEventListener("click", (event) => { 223 | if (event.target.id === "make") { 224 | if (!document.getElementById("carbonads")) return; 225 | if (typeof _carbonads !== "undefined") _carbonads.refresh(); 226 | } 227 | }); 228 | -------------------------------------------------------------------------------- /src/vendor/js/clipboard.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v2.0.11 3 | * https://clipboardjs.com/ 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return b}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),r=n.n(e);function c(t){try{return document.execCommand(t)}catch(t){return}}var a=function(t){t=r()(t);return c("cut"),t};function o(t,e){var n,o,t=(n=t,o="rtl"===document.documentElement.getAttribute("dir"),(t=document.createElement("textarea")).style.fontSize="12pt",t.style.border="0",t.style.padding="0",t.style.margin="0",t.style.position="absolute",t.style[o?"right":"left"]="-9999px",o=window.pageYOffset||document.documentElement.scrollTop,t.style.top="".concat(o,"px"),t.setAttribute("readonly",""),t.value=n,t);return e.container.appendChild(t),e=r()(t),c("copy"),t.remove(),e}var f=function(t){var e=1 { 2 | const shareElements = { 3 | openButton: document.getElementById("share-open"), 4 | modal: document.getElementById("share-dialog"), 5 | closeButton: document.getElementById("share-close"), 6 | input: document.getElementById("share-link-input"), 7 | copyButton: document.getElementById("share-copy"), 8 | copyStatus: document.getElementById("share-copy-status") 9 | }; 10 | 11 | let pageScrollY = 0; 12 | let handleOutsidePointerDown = null; 13 | 14 | const prefersReducedMotion = () => { 15 | return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; 16 | }; 17 | 18 | const lockBodyScroll = () => { 19 | if (document.body.classList.contains("modal-open")) return; 20 | pageScrollY = window.scrollY || document.documentElement.scrollTop || 0; 21 | document.body.style.position = "fixed"; 22 | document.body.style.top = `-${pageScrollY}px`; 23 | document.body.style.left = "0"; 24 | document.body.style.right = "0"; 25 | document.body.style.width = "100%"; 26 | document.body.classList.add("modal-open"); 27 | }; 28 | 29 | const unlockBodyScroll = () => { 30 | const scrollToY = pageScrollY || 0; 31 | document.body.classList.remove("modal-open"); 32 | document.body.style.position = ""; 33 | document.body.style.top = ""; 34 | document.body.style.left = ""; 35 | document.body.style.right = ""; 36 | document.body.style.width = ""; 37 | window.scrollTo(0, scrollToY); 38 | }; 39 | 40 | const getDialogFocusable = () => { 41 | if (!shareElements.modal) return []; 42 | const selectors = [ 43 | "button", 44 | "[href]", 45 | 'input:not([type="hidden"])', 46 | "select", 47 | "textarea", 48 | "[tabindex]:not([tabindex='-1'])" 49 | ]; 50 | const nodes = Array.from(shareElements.modal.querySelectorAll(selectors.join(","))); 51 | return nodes.filter((node) => { 52 | const tabIndex = node.tabIndex; 53 | const isHidden = node.getAttribute("aria-hidden") === "true"; 54 | const isDisabled = node.hasAttribute("disabled"); 55 | return !isHidden && !isDisabled && tabIndex !== -1; 56 | }); 57 | }; 58 | 59 | const resizeShareInputHeight = () => { 60 | if (!shareElements.input) return; 61 | shareElements.input.style.height = "auto"; 62 | shareElements.input.style.height = `${shareElements.input.scrollHeight}px`; 63 | }; 64 | 65 | const updateShareLinkValue = () => { 66 | if (!shareElements.input) return; 67 | shareElements.input.value = window.location.href; 68 | resizeShareInputHeight(); 69 | }; 70 | 71 | const copyShareLink = async () => { 72 | if (!shareElements.input) return; 73 | const text = shareElements.input.value; 74 | if (!text) return; 75 | try { 76 | if (navigator.clipboard && navigator.clipboard.writeText) { 77 | await navigator.clipboard.writeText(text); 78 | } else { 79 | const helper = document.createElement("textarea"); 80 | helper.value = text; 81 | helper.setAttribute("readonly", ""); 82 | helper.style.position = "absolute"; 83 | helper.style.left = "-9999px"; 84 | document.body.appendChild(helper); 85 | helper.select(); 86 | document.execCommand("copy"); 87 | document.body.removeChild(helper); 88 | } 89 | const btn = shareElements.copyButton; 90 | if (btn) { 91 | btn.classList.add("copied"); 92 | btn.disabled = true; 93 | btn.setAttribute("aria-disabled", "true"); 94 | setTimeout(() => { 95 | btn.classList.remove("copied"); 96 | btn.disabled = false; 97 | btn.setAttribute("aria-disabled", "false"); 98 | }, 1500); 99 | } 100 | const status = shareElements.copyStatus; 101 | if (status) { 102 | status.textContent = "Copied share link to clipboard."; 103 | setTimeout(() => { 104 | status.textContent = ""; 105 | }, 4500); 106 | } 107 | } catch (err) { 108 | console.error(err); 109 | } 110 | }; 111 | 112 | const focusCopyButton = () => { 113 | if (!shareElements.copyButton) return; 114 | try { 115 | shareElements.copyButton.focus({ preventScroll: true }); 116 | } catch (error) { 117 | shareElements.copyButton.focus(); 118 | } 119 | }; 120 | 121 | const openShareModal = () => { 122 | if (!shareElements.modal) return; 123 | updateShareLinkValue(); 124 | lockBodyScroll(); 125 | if (typeof shareElements.modal.showModal === "function") { 126 | shareElements.modal.showModal(); 127 | } else { 128 | shareElements.modal.setAttribute("open", "true"); 129 | } 130 | shareElements.modal.classList.remove("is-closing"); 131 | shareElements.modal.removeAttribute("data-closing"); 132 | if (!prefersReducedMotion()) { 133 | shareElements.modal.classList.add("is-opening"); 134 | shareElements.modal.addEventListener("animationend", (event) => { 135 | if (event.target === shareElements.modal && event.animationName === "export-dialog-fade") { 136 | shareElements.modal.classList.remove("is-opening"); 137 | } 138 | }, { once: true }); 139 | } else { 140 | shareElements.modal.classList.remove("is-opening"); 141 | } 142 | if (!handleOutsidePointerDown) { 143 | handleOutsidePointerDown = (event) => { 144 | if (!shareElements.modal || !shareElements.modal.open) return; 145 | const rect = shareElements.modal.getBoundingClientRect(); 146 | const isOutside = event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; 147 | if (isOutside) { 148 | closeShareModal(); 149 | } 150 | }; 151 | document.addEventListener("pointerdown", handleOutsidePointerDown); 152 | } 153 | if (shareElements.openButton) { 154 | shareElements.openButton.setAttribute("aria-expanded", "true"); 155 | } 156 | requestAnimationFrame(() => { 157 | resizeShareInputHeight(); 158 | focusCopyButton(); 159 | }); 160 | }; 161 | 162 | const closeShareModal = () => { 163 | const modal = shareElements.modal; 164 | if (!modal) return; 165 | if (modal.getAttribute("data-closing") === "true") return; 166 | 167 | const completeClose = () => { 168 | modal.removeAttribute("data-closing"); 169 | modal.classList.remove("is-closing"); 170 | modal.classList.remove("is-opening"); 171 | if (modal.open && typeof modal.close === "function") { 172 | modal.close(); 173 | } else { 174 | modal.removeAttribute("open"); 175 | } 176 | unlockBodyScroll(); 177 | if (handleOutsidePointerDown) { 178 | document.removeEventListener("pointerdown", handleOutsidePointerDown); 179 | handleOutsidePointerDown = null; 180 | } 181 | if (shareElements.openButton) { 182 | shareElements.openButton.setAttribute("aria-expanded", "false"); 183 | } 184 | }; 185 | 186 | if (!modal.open && !modal.hasAttribute("open")) { 187 | completeClose(); 188 | return; 189 | } 190 | 191 | if (prefersReducedMotion()) { 192 | completeClose(); 193 | return; 194 | } 195 | 196 | modal.setAttribute("data-closing", "true"); 197 | modal.classList.remove("is-opening"); 198 | modal.classList.add("is-closing"); 199 | 200 | let closeFallbackTimer = null; 201 | const handleAnimationEnd = (event) => { 202 | if (event.target !== modal || event.animationName !== "export-dialog-fade") return; 203 | clearTimeout(closeFallbackTimer); 204 | modal.removeEventListener("animationend", handleAnimationEnd); 205 | completeClose(); 206 | }; 207 | 208 | closeFallbackTimer = setTimeout(() => { 209 | modal.removeEventListener("animationend", handleAnimationEnd); 210 | completeClose(); 211 | }, 250); 212 | 213 | modal.addEventListener("animationend", handleAnimationEnd); 214 | }; 215 | 216 | const wireShareControls = () => { 217 | if (shareElements.openButton) { 218 | shareElements.openButton.addEventListener("click", () => openShareModal()); 219 | } 220 | 221 | if (shareElements.closeButton) { 222 | shareElements.closeButton.addEventListener("click", () => closeShareModal()); 223 | } 224 | 225 | if (shareElements.modal) { 226 | shareElements.modal.addEventListener("cancel", (event) => { 227 | event.preventDefault(); 228 | closeShareModal(); 229 | }); 230 | shareElements.modal.addEventListener("click", (event) => { 231 | event.stopPropagation(); 232 | }); 233 | shareElements.modal.addEventListener("keydown", (event) => { 234 | if (event.key === "Escape") { 235 | event.preventDefault(); 236 | closeShareModal(); 237 | return; 238 | } 239 | if (event.key === "Tab") { 240 | const focusables = getDialogFocusable(); 241 | if (!focusables.length) return; 242 | const currentIndex = focusables.indexOf(document.activeElement); 243 | let nextIndex = currentIndex; 244 | if (event.shiftKey) { 245 | nextIndex = currentIndex <= 0 ? focusables.length - 1 : currentIndex - 1; 246 | } else { 247 | nextIndex = currentIndex === focusables.length - 1 ? 0 : currentIndex + 1; 248 | } 249 | focusables[nextIndex].focus(); 250 | event.preventDefault(); 251 | } 252 | }); 253 | } 254 | 255 | if (shareElements.copyButton) { 256 | shareElements.copyButton.addEventListener("click", () => copyShareLink()); 257 | } 258 | 259 | if (shareElements.input) { 260 | shareElements.input.setAttribute("tabindex", "-1"); 261 | shareElements.input.addEventListener("click", () => { 262 | shareElements.input.select(); 263 | }); 264 | } 265 | }; 266 | 267 | wireShareControls(); 268 | 269 | window.shareUI = { 270 | openShareModal, 271 | closeShareModal, 272 | elements: shareElements 273 | }; 274 | })(); 275 | -------------------------------------------------------------------------------- /src/vendor/js/coloris.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof module&&module.exports?module.exports=t():(e.Coloris=t(),"object"==typeof window&&e.Coloris.init())}("undefined"!=typeof self?self:void 0,function(){{var L=window,x=document,A=Math,C=void 0;const Z=x.createElement("canvas").getContext("2d"),_={r:0,g:0,b:0,h:0,s:0,v:0,a:1};let u,d,p,i,s,h,f,b,y,a,v,m,g,w,l,n,k={};const ee={el:"[data-coloris]",parent:"body",theme:"default",themeMode:"light",rtl:!1,wrap:!0,margin:2,format:"hex",formatToggle:!1,swatches:[],swatchesOnly:!1,alpha:!0,forceAlpha:!1,focusInput:!0,selectInput:!1,inline:!1,defaultColor:"#000000",clearButton:!1,clearLabel:"Clear",closeButton:!1,closeLabel:"Close",onChange:()=>C,a11y:{open:"Open color picker",close:"Close color picker",clear:"Clear the selected color",marker:"Saturation: {s}. Brightness: {v}.",hueSlider:"Hue slider",alphaSlider:"Opacity slider",input:"Color value field",format:"Color format",swatch:"Color swatch",instruction:"Saturation and brightness selector. Use up, down, left and right arrow keys to select."}},te={};let o="",c={},E=!1;function S(t){if("object"==typeof t)for(const o in t)switch(o){case"el":B(t.el),!1!==t.wrap&&H(t.el);break;case"parent":(u=t.parent instanceof HTMLElement?t.parent:x.querySelector(t.parent))&&(u.appendChild(d),ee.parent=t.parent,u===x.body)&&(u=C);break;case"themeMode":ee.themeMode=t.themeMode,"auto"===t.themeMode&&L.matchMedia&&L.matchMedia("(prefers-color-scheme: dark)").matches&&(ee.themeMode="dark");case"theme":t.theme&&(ee.theme=t.theme),d.className=`clr-picker clr-${ee.theme} clr-`+ee.themeMode,ee.inline&&M();break;case"rtl":ee.rtl=!!t.rtl,Array.from(x.getElementsByClassName("clr-field")).forEach(e=>e.classList.toggle("clr-rtl",ee.rtl));break;case"margin":t.margin*=1,ee.margin=(isNaN(t.margin)?ee:t).margin;break;case"wrap":t.el&&t.wrap&&H(t.el);break;case"formatToggle":ee.formatToggle=!!t.formatToggle,K("clr-format").style.display=ee.formatToggle?"block":"none",ee.formatToggle&&(ee.format="auto");break;case"swatches":if(Array.isArray(t.swatches)){var l=K("clr-swatches");const c=x.createElement("div");l.textContent="",t.swatches.forEach((e,t)=>{var l=x.createElement("button");l.setAttribute("type","button"),l.setAttribute("id","clr-swatch-"+t),l.setAttribute("aria-labelledby","clr-swatch-label clr-swatch-"+t),l.style.color=e,l.textContent=e,c.appendChild(l)}),t.swatches.length&&l.appendChild(c),ee.swatches=t.swatches.slice()}break;case"swatchesOnly":ee.swatchesOnly=!!t.swatchesOnly,d.setAttribute("data-minimal",ee.swatchesOnly);break;case"alpha":ee.alpha=!!t.alpha,d.setAttribute("data-alpha",ee.alpha);break;case"inline":ee.inline=!!t.inline,d.setAttribute("data-inline",ee.inline),ee.inline&&(l=t.defaultColor||ee.defaultColor,w=j(l),M(),I(l));break;case"clearButton":"object"==typeof t.clearButton&&(t.clearButton.label&&(ee.clearLabel=t.clearButton.label,f.innerHTML=ee.clearLabel),t.clearButton=t.clearButton.show),ee.clearButton=!!t.clearButton,f.style.display=ee.clearButton?"block":"none";break;case"clearLabel":ee.clearLabel=t.clearLabel,f.innerHTML=ee.clearLabel;break;case"closeButton":ee.closeButton=!!t.closeButton,ee.closeButton?d.insertBefore(b,s):s.appendChild(b);break;case"closeLabel":ee.closeLabel=t.closeLabel,b.innerHTML=ee.closeLabel;break;case"a11y":var a,r,n=t.a11y;let e=!1;if("object"==typeof n)for(const i in n)n[i]&&ee.a11y[i]&&(ee.a11y[i]=n[i],e=!0);e&&(a=K("clr-open-label"),r=K("clr-swatch-label"),a.innerHTML=ee.a11y.open,r.innerHTML=ee.a11y.swatch,b.setAttribute("aria-label",ee.a11y.close),f.setAttribute("aria-label",ee.a11y.clear),y.setAttribute("aria-label",ee.a11y.hueSlider),v.setAttribute("aria-label",ee.a11y.alphaSlider),h.setAttribute("aria-label",ee.a11y.input),p.setAttribute("aria-label",ee.a11y.instruction));break;default:ee[o]=t[o]}}function e(e,t){"string"==typeof e&&"object"==typeof t&&(te[e]=t,E=!0)}function T(e){delete te[e],0===Object.keys(te).length&&(E=!1,e===o)&&$()}function r(e){if(E){var t,l=["el","wrap","rtl","inline","defaultColor","a11y"];for(t in te){const r=te[t];if(e.matches(t)){for(var a in o=t,c={},l.forEach(e=>delete r[e]),r)c[a]=Array.isArray(ee[a])?ee[a].slice():ee[a];S(r);break}}}}function $(){0{J(e,"click",t),J(e,"input",O)}):(J(x,"click",e,t),J(x,"input",e,O))}function t(e){ee.inline||(r(e.target),g=e.target,l=g.value,w=j(l),d.classList.add("clr-open"),M(),I(l),(ee.focusInput||ee.selectInput)&&(h.focus({preventScroll:!0}),h.setSelectionRange(g.selectionStart,g.selectionEnd)),ee.selectInput&&h.select(),(n||ee.swatchesOnly)&&G().shift().focus(),g.dispatchEvent(new Event("open",{bubbles:!1})))}function M(){if(d&&(g||ee.inline)){var r=u,n=L.scrollY,o=d.offsetWidth,c=d.offsetHeight,i={left:!1,top:!1};let e,l,t,a={x:0,y:0};if(r&&(e=L.getComputedStyle(r),l=parseFloat(e.marginTop),t=parseFloat(e.borderTopWidth),(a=r.getBoundingClientRect()).y+=t+n),!ee.inline){var s=g.getBoundingClientRect();let e=s.x,t=n+s.y+s.height+ee.margin;r?(e-=a.x,t-=a.y,e+o>r.clientWidth&&(e+=s.width-o,i.left=!0),t+c>r.clientHeight-l&&c+ee.margin<=s.top-(a.y-n)&&(t-=s.height+c+2*ee.margin,i.top=!0),t+=r.scrollTop):(e+o>x.documentElement.clientWidth&&(e+=s.width-o,i.left=!0),t+c-n>x.documentElement.clientHeight&&c+ee.margin<=s.top&&(t=n+s.y-c-ee.margin,i.top=!0)),d.classList.toggle("clr-left",i.left),d.classList.toggle("clr-top",i.top),d.style.left=e+"px",d.style.top=t+"px",a.x+=d.offsetLeft,a.y+=d.offsetTop}k={width:p.offsetWidth,height:p.offsetHeight,x:p.offsetLeft+a.x,y:p.offsetTop+a.y}}}function H(e){e instanceof HTMLElement?N(e):(Array.isArray(e)?e:x.querySelectorAll(e)).forEach(N)}function N(t){var l=t.parentNode;if(!l.classList.contains("clr-field")){var a=x.createElement("div");let e="clr-field";(ee.rtl||t.classList.contains("clr-rtl"))&&(e+=" clr-rtl"),a.innerHTML='',l.insertBefore(a,t),a.className=e,a.style.color=t.value,a.appendChild(t)}}function O(e){var t=e.target.parentNode;t.classList.contains("clr-field")&&(t.style.color=e.target.value)}function D(e){if(g&&!ee.inline){const t=g;e&&(g=C,l!==t.value)&&(t.value=l,t.dispatchEvent(new Event("input",{bubbles:!0}))),setTimeout(()=>{l!==t.value&&t.dispatchEvent(new Event("change",{bubbles:!0}))}),d.classList.remove("clr-open"),E&&$(),t.dispatchEvent(new Event("close",{bubbles:!1})),ee.focusInput&&t.focus({preventScroll:!0}),g=C}}function I(e){var e=function(e){let t,l;Z.fillStyle="#000",Z.fillStyle=e,l=(t=/^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i.exec(Z.fillStyle))?{r:+t[3],g:+t[4],b:+t[5],a:+t[6]}:(t=Z.fillStyle.replace("#","").match(/.{2}/g).map(e=>parseInt(e,16)),{r:t[0],g:t[1],b:t[2],a:1});return l}(e),t=function(e){var t=e.r/255,l=e.g/255,a=e.b/255,r=A.max(t,l,a),n=A.min(t,l,a),n=r-n,o=r;let c=0,i=0;n&&(r===t&&(c=(l-a)/n),r===l&&(c=2+(a-t)/n),r===a&&(c=4+(t-l)/n),r)&&(i=n/r);return{h:(c=A.floor(60*c))<0?c+360:c,s:A.round(100*i),v:A.round(100*o),a:e.a}}(e);P(t.s,t.v),U(e,t),y.value=t.h,d.style.color=`hsl(${t.h}, 100%, 50%)`,a.style.left=t.h/360*100+"%",i.style.left=k.width*t.s/100+"px",i.style.top=k.height-k.height*t.v/100+"px",v.value=100*t.a,m.style.left=100*t.a+"%"}function j(e){e=e.substring(0,3).toLowerCase();return"rgb"===e||"hsl"===e?e:"hex"}function R(e){e=e!==C?e:h.value,g&&(g.value=e,g.dispatchEvent(new Event("input",{bubbles:!0}))),ee.onChange&&ee.onChange.call(L,e,g),x.dispatchEvent(new CustomEvent("coloris:pick",{detail:{color:e,currentEl:g}}))}function W(e,t){var l,a,r,n,o,e={h:+y.value,s:e/k.width*100,v:100-t/k.height*100,a:v.value/100},c=(c=(t=e).s/100,l=t.v/100,c*=l,a=t.h/60,r=c*(1-A.abs(a%2-1)),c+=l-=c,r+=l,a=A.floor(a)%6,n=[c,r,l,l,r,c][a],o=[r,c,c,r,l,l][a],l=[l,l,r,c,c,r][a],{r:A.round(255*n),g:A.round(255*o),b:A.round(255*l),a:t.a});P(e.s,e.v),U(c,e),R()}function P(e,t){let l=ee.a11y.marker;e=+e.toFixed(1),t=+t.toFixed(1),l=(l=l.replace("{s}",e)).replace("{v}",t),i.setAttribute("aria-label",l)}function q(e){var t={pageX:((t=e).changedTouches?t.changedTouches[0]:t).pageX,pageY:(t.changedTouches?t.changedTouches[0]:t).pageY},l=t.pageX-k.x;let a=t.pageY-k.y;u&&(a+=u.scrollTop),F(l,a),e.preventDefault(),e.stopPropagation()}function F(e,t){e=e<0?0:e>k.width?k.width:e,t=t<0?0:t>k.height?k.height:t,i.style.left=e+"px",i.style.top=t+"px",W(e,t),i.focus()}function U(e,t){void 0===e&&(e={}),void 0===t&&(t={});let l=ee.format;for(const o in e)_[o]=e[o];for(const c in t)_[c]=t[c];var a,r=function(e){let t=e.r.toString(16),l=e.g.toString(16),a=e.b.toString(16),r="";e.r<16&&(t="0"+t);e.g<16&&(l="0"+l);e.b<16&&(a="0"+a);ee.alpha&&(e.a<1||ee.forceAlpha)&&(e=255*e.a|0,r=e.toString(16),e<16)&&(r="0"+r);return"#"+t+l+a+r}(_),n=r.substring(0,7);switch(i.style.color=n,m.parentNode.style.color=n,m.style.color=r,s.style.color=r,p.style.display="none",p.offsetHeight,p.style.display="",m.nextElementSibling.style.display="none",m.nextElementSibling.offsetHeight,m.nextElementSibling.style.display="","mixed"===l?l=1===_.a?"hex":"rgb":"auto"===l&&(l=w),l){case"hex":h.value=r;break;case"rgb":h.value=(a=_,!ee.alpha||1===a.a&&!ee.forceAlpha?`rgb(${a.r}, ${a.g}, ${a.b})`:`rgba(${a.r}, ${a.g}, ${a.b}, ${a.a})`);break;case"hsl":h.value=(a=function(e){var t=e.v/100,l=t*(1-e.s/100/2);let a;0`+`
`+'
'+``+'
'+``+'
'+`${ee.a11y.format}`+'
'+``+'
'+``+"
"+``+``,x.body.appendChild(d),p=K("clr-color-area"),i=K("clr-color-marker"),f=K("clr-clear"),b=K("clr-close"),s=K("clr-color-preview"),h=K("clr-color-value"),y=K("clr-hue-slider"),a=K("clr-hue-marker"),v=K("clr-alpha-slider"),m=K("clr-alpha-marker"),B(ee.el),H(ee.el),J(d,"mousedown",e=>{d.classList.remove("clr-keyboard-nav"),e.stopPropagation()}),J(p,"mousedown",e=>{J(x,"mousemove",q)}),J(p,"contextmenu",e=>{e.preventDefault()}),J(p,"touchstart",e=>{x.addEventListener("touchmove",q,{passive:!1})}),J(i,"mousedown",e=>{J(x,"mousemove",q)}),J(i,"touchstart",e=>{x.addEventListener("touchmove",q,{passive:!1})}),J(h,"change",e=>{var t=h.value;(g||ee.inline)&&R(""===t?t:I(t))}),J(f,"click",e=>{R(""),D()}),J(b,"click",e=>{R(),D()}),J(K("clr-format"),"click",".clr-format input",e=>{w=e.target.value,U(),R()}),J(d,"click",".clr-swatches button",e=>{I(e.target.textContent),R(),ee.swatchesOnly&&D()}),J(x,"mouseup",e=>{x.removeEventListener("mousemove",q)}),J(x,"touchend",e=>{x.removeEventListener("touchmove",q)}),J(x,"mousedown",e=>{n=!1,d.classList.remove("clr-keyboard-nav"),D()}),J(x,"keydown",e=>{var t,l=e.key,a=e.target,r=e.shiftKey;"Escape"===l?D(!0):"Enter"===l&&"BUTTON"!==a.tagName?D():(["Tab","ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(l)&&(n=!0,d.classList.add("clr-keyboard-nav")),"Tab"===l&&a.matches(".clr-picker *")&&(t=(l=G()).shift(),l=l.pop(),r&&a===t?(l.focus(),e.preventDefault()):r||a!==l||(t.focus(),e.preventDefault())))}),J(x,"click",".clr-field button",e=>{E&&$(),e.target.nextElementSibling.dispatchEvent(new Event("click",{bubbles:!0}))}),J(i,"keydown",e=>{var t,l={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0]};Object.keys(l).includes(e.key)&&([l,t]=[...l[e.key]],F(+i.style.left.replace("px","")+l,+i.style.top.replace("px","")+t),e.preventDefault())}),J(p,"click",q),J(y,"input",Y),J(v,"input",X))}function G(){return Array.from(d.querySelectorAll("input, button")).filter(e=>!!e.offsetWidth)}function K(e){return x.getElementById(e)}function J(e,t,l,a){const r=Element.prototype.matches||Element.prototype.msMatchesSelector;"string"==typeof l?e.addEventListener(t,e=>{r.call(e.target,l)&&a.call(e.target,e)}):(a=l,e.addEventListener(t,a))}function Q(e,t){t=t!==C?t:[],"loading"!==x.readyState?e(...t):x.addEventListener("DOMContentLoaded",()=>{e(...t)})}function le(e,t){g=t,l=g.value,r(t),w=j(e),M(),I(e),R(),l!==e&&g.dispatchEvent(new Event("change",{bubbles:!0}))}NodeList!==C&&NodeList.prototype&&!NodeList.prototype.forEach&&(NodeList.prototype.forEach=Array.prototype.forEach);var V=(()=>{const a={init:z,set:S,wrap:H,close:D,setInstance:e,setColor:le,removeInstance:T,updatePosition:M,ready:Q};function t(e){Q(()=>{e&&("string"==typeof e?B:S)(e)})}for(const r in a)t[r]=function(){for(var e=arguments.length,t=new Array(e),l=0;l{L.addEventListener("resize",e=>{t.updatePosition()}),L.addEventListener("scroll",e=>{t.updatePosition()})}),t})();return V.coloris=V}}); -------------------------------------------------------------------------------- /src/js/color-picker.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const colorInput = document.getElementById("color-values"); 3 | const pickerInput = document.getElementById("color-picker-input"); 4 | const pickerButton = document.getElementById("color-picker-button"); 5 | const submitButton = document.getElementById("make"); 6 | 7 | if (!colorInput || !pickerInput || !pickerButton || typeof Coloris === "undefined" || !window.palettes) return; 8 | 9 | const defaultColor = "#3b82f6"; 10 | let pendingHex = ""; 11 | let activeContext = { mode: "add", index: null }; 12 | let hasCommittedThisSession = false; 13 | let shouldCommit = false; 14 | let activePickerCell = null; 15 | let isPickerOpen = false; 16 | let focusButtonOnEsc = false; 17 | let pendingFocusTarget = null; 18 | let focusReturnTarget = null; 19 | let suppressNextPalettePickerOpen = null; 20 | let suppressNextPickerButtonOpen = false; 21 | const GLOBAL_GUARD = "__tsColorPickerDocumentHandlersBound"; 22 | const ACTIVATION_KEYS = new Set(["enter", "return", "numpadenter", " ", "space", "spacebar"]); 23 | const ACTIVATION_KEY_CODES = new Set([13, 32]); 24 | const isActivationKey = (value) => { 25 | if (typeof value === "string") { 26 | return ACTIVATION_KEYS.has(value.toLowerCase()); 27 | } 28 | if (typeof value === "number") { 29 | return ACTIVATION_KEY_CODES.has(value); 30 | } 31 | if (value && typeof value === "object") { 32 | if (isActivationKey(value.key)) return true; 33 | if (isActivationKey(value.code)) return true; 34 | const keyCode = typeof value.keyCode === "number" ? value.keyCode : value.which; 35 | if (typeof keyCode === "number") { 36 | return isActivationKey(keyCode); 37 | } 38 | } 39 | return false; 40 | }; 41 | const getEventTargetElement = (event) => { 42 | let node = event && event.target; 43 | while (node) { 44 | if (node instanceof Element) return node; 45 | node = node.parentNode; 46 | } 47 | return null; 48 | }; 49 | const closestFromEvent = (event, selector) => { 50 | const target = getEventTargetElement(event); 51 | return target ? target.closest(selector) : null; 52 | }; 53 | 54 | const clearActivePickerCell = () => { 55 | if (activePickerCell && activePickerCell.classList) { 56 | activePickerCell.classList.remove("is-picker-open"); 57 | } 58 | activePickerCell = null; 59 | }; 60 | 61 | const activatePickerCell = (target) => { 62 | clearActivePickerCell(); 63 | if (target && target.classList && target.classList.contains("edit-base-button")) { 64 | activePickerCell = target; 65 | activePickerCell.classList.add("is-picker-open"); 66 | } 67 | }; 68 | 69 | const focusEl = (el) => { 70 | if (el && typeof el.focus === "function") { 71 | el.focus({ preventScroll: true }); 72 | } 73 | }; 74 | 75 | const suppressTooltipUntilMouseOut = (target) => { 76 | if (!target || !target.setAttribute) return; 77 | target.setAttribute("data-tooltip-suppressed", "true"); 78 | const clear = () => { 79 | target.removeAttribute("data-tooltip-suppressed"); 80 | }; 81 | target.addEventListener("mouseleave", clear, { once: true }); 82 | target.addEventListener("pointerleave", clear, { once: true }); 83 | }; 84 | 85 | const handlePickerFocusIn = (event) => { 86 | if (!isPickerOpen) return; 87 | const picker = document.getElementById("clr-picker"); 88 | if (focusButtonOnEsc) return; 89 | if (picker && picker.contains(event.target)) return; 90 | const palettePickerButton = closestFromEvent(event, ".edit-base-button"); 91 | if (palettePickerButton && palettePickerButton === activePickerCell) { 92 | suppressNextPalettePickerOpen = palettePickerButton; 93 | } 94 | pendingFocusTarget = event.target; 95 | Coloris.close(); 96 | }; 97 | 98 | const handlePickerEscape = (event) => { 99 | if (event.defaultPrevented) return; 100 | if (!isPickerOpen || event.key !== "Escape") return; 101 | event.preventDefault(); 102 | event.stopImmediatePropagation(); 103 | event.stopPropagation(); 104 | focusButtonOnEsc = true; 105 | Coloris.close(); 106 | }; 107 | 108 | const getPickerCloseButton = () => document.getElementById("clr-close"); 109 | 110 | const getPickerFocusableElements = () => { 111 | const picker = document.getElementById("clr-picker"); 112 | if (!picker) return []; 113 | return Array.from(picker.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])')).filter((element) => { 114 | if (element.disabled) return false; 115 | if (element.getAttribute("tabindex") === "0" && element.matches("div, span, p, section")) return false; 116 | if (typeof element.tabIndex === "number" && element.tabIndex < 0) return false; 117 | if (!(element instanceof HTMLElement)) return false; 118 | return element.offsetParent !== null || element.getClientRects().length > 0; 119 | }); 120 | }; 121 | 122 | const getDocumentFocusableElements = () => { 123 | const focusableSelectors = [ 124 | 'a[href]', 125 | 'area[href]', 126 | 'input:not([type="hidden"]):not([disabled])', 127 | 'select:not([disabled])', 128 | 'textarea:not([disabled])', 129 | 'button:not([disabled])', 130 | 'iframe', 131 | '[tabindex]:not([tabindex="-1"])', 132 | '[contenteditable="true"]' 133 | ]; 134 | return Array.from(document.querySelectorAll(focusableSelectors.join(","))).filter((element) => { 135 | if (!(element instanceof HTMLElement)) return false; 136 | if (element.hasAttribute("disabled")) return false; 137 | if (element.getAttribute("tabindex") === "0" && element.matches("div, span, p, section")) return false; 138 | if (typeof element.tabIndex === "number" && element.tabIndex < 0) return false; 139 | if (element.closest && element.closest("#clr-picker")) return false; 140 | return element.offsetParent !== null || element.getClientRects().length > 0; 141 | }); 142 | }; 143 | 144 | const getNextFocusableAfterTrigger = (direction) => { 145 | const reference = focusReturnTarget || pickerButton; 146 | const focusableElements = getDocumentFocusableElements(); 147 | if (!focusableElements.length || !reference) return null; 148 | 149 | if (reference === pickerButton && direction > 0 && submitButton) { 150 | return submitButton; 151 | } 152 | 153 | const currentIndex = focusableElements.indexOf(reference); 154 | if (currentIndex === -1) { 155 | return direction > 0 ? focusableElements[0] : focusableElements[focusableElements.length - 1]; 156 | } 157 | 158 | const nextIndex = currentIndex + direction; 159 | if (nextIndex < 0 || nextIndex >= focusableElements.length) return null; 160 | return focusableElements[nextIndex]; 161 | }; 162 | 163 | const handlePickerTabNavigation = (event) => { 164 | if (event.defaultPrevented) return; 165 | if (!isPickerOpen || event.key !== "Tab") return; 166 | const picker = document.getElementById("clr-picker"); 167 | if (!picker || !picker.contains(event.target)) return; 168 | 169 | const focusableElements = getPickerFocusableElements(); 170 | if (!focusableElements.length) return; 171 | 172 | const firstFocusable = focusableElements[0]; 173 | const lastFocusable = focusableElements[focusableElements.length - 1]; 174 | const shouldExitForward = !event.shiftKey && event.target === lastFocusable; 175 | const shouldExitBackward = event.shiftKey && event.target === firstFocusable; 176 | if (!shouldExitForward && !shouldExitBackward) return; 177 | 178 | const direction = shouldExitBackward ? -1 : 1; 179 | const nextElement = getNextFocusableAfterTrigger(direction); 180 | if (!nextElement) return; 181 | 182 | event.preventDefault(); 183 | pendingFocusTarget = nextElement; 184 | focusReturnTarget = null; 185 | Coloris.close(); 186 | }; 187 | 188 | const handlePickerEnterCommit = (event) => { 189 | if (event.defaultPrevented) return; 190 | if (!isPickerOpen || event.key !== "Enter") return; 191 | const picker = document.getElementById("clr-picker"); 192 | if (!picker || !picker.contains(event.target)) return; 193 | if (event.target.tagName === "BUTTON") return; 194 | if (event.target.id === "clr-color-value") return; 195 | event.preventDefault(); 196 | event.stopImmediatePropagation(); 197 | event.stopPropagation(); 198 | const closeButton = getPickerCloseButton(); 199 | if (closeButton) { 200 | focusEl(closeButton); 201 | } 202 | }; 203 | 204 | const handlePalettePickerClick = (event) => { 205 | const palettePickerButton = closestFromEvent(event, ".edit-base-button"); 206 | if (!palettePickerButton) return; 207 | event.preventDefault(); 208 | if (suppressNextPalettePickerOpen === palettePickerButton) { 209 | suppressNextPalettePickerOpen = null; 210 | return; 211 | } 212 | if (isPickerOpen && activePickerCell === palettePickerButton) { 213 | focusReturnTarget = palettePickerButton; 214 | suppressTooltipUntilMouseOut(palettePickerButton); 215 | Coloris.close(); 216 | return; 217 | } 218 | const colorIndex = parseInt(palettePickerButton.getAttribute("data-color-index"), 10); 219 | const colorHex = normalizeHex(palettePickerButton.getAttribute("data-color-hex")); 220 | const rowType = palettePickerButton.getAttribute("data-row-type") || null; 221 | openPicker({ 222 | target: palettePickerButton, 223 | baseHex: colorHex ? `#${colorHex}` : null, 224 | mode: "edit", 225 | index: colorIndex, 226 | rowType 227 | }); 228 | }; 229 | 230 | const handlePalettePickerPointerDown = (event) => { 231 | const palettePickerButton = closestFromEvent(event, ".edit-base-button"); 232 | if (!palettePickerButton) return; 233 | if (!isPickerOpen || activePickerCell !== palettePickerButton) return; 234 | suppressNextPalettePickerOpen = palettePickerButton; 235 | focusReturnTarget = palettePickerButton; 236 | suppressTooltipUntilMouseOut(palettePickerButton); 237 | Coloris.close(); 238 | event.preventDefault(); 239 | event.stopPropagation(); 240 | }; 241 | 242 | if (!window[GLOBAL_GUARD]) { 243 | document.addEventListener("focusin", handlePickerFocusIn); 244 | document.addEventListener("keydown", handlePickerEscape); 245 | document.addEventListener("keydown", handlePickerTabNavigation, true); 246 | document.addEventListener("keydown", handlePickerEnterCommit, true); 247 | document.addEventListener("pointerdown", handlePalettePickerPointerDown); 248 | document.addEventListener("click", handlePalettePickerClick); 249 | window[GLOBAL_GUARD] = true; 250 | } 251 | 252 | const normalizeHex = (value) => { 253 | if (!value) return ""; 254 | const raw = value.toString().trim(); 255 | const withoutHash = raw.startsWith("#") ? raw.slice(1) : raw; 256 | const clean = withoutHash.replace(/[^0-9a-f]/gi, "").slice(0, 6).toLowerCase(); 257 | if (clean.length === 3) { 258 | return clean.split("").map((char) => char + char).join(""); 259 | } 260 | if (clean.length !== 6) return ""; 261 | return clean; 262 | }; 263 | 264 | const getThemeMode = () => (document.documentElement.classList.contains("darkmode-active") ? "dark" : "light"); 265 | const WINDOW_REFRESH_HANDLER = "__tsColorPickerWindowRefreshHandler"; 266 | 267 | let activePickerAnchor = null; 268 | 269 | const updatePickerInputPosition = () => { 270 | if (!pickerInput || !activePickerAnchor) return; 271 | const rect = activePickerAnchor.getBoundingClientRect(); 272 | pickerInput.style.position = "fixed"; 273 | pickerInput.style.left = `${rect.left}px`; 274 | pickerInput.style.top = `${rect.top}px`; 275 | pickerInput.style.width = `${rect.width}px`; 276 | pickerInput.style.height = `${rect.height}px`; 277 | pickerInput.style.pointerEvents = "none"; 278 | pickerInput.style.opacity = "0"; 279 | }; 280 | 281 | const positionPickerInput = (target) => { 282 | if (!pickerInput || !target) return; 283 | activePickerAnchor = target; 284 | updatePickerInputPosition(); 285 | }; 286 | 287 | const refreshPickerPosition = () => { 288 | if (!activePickerAnchor) return; 289 | updatePickerInputPosition(); 290 | }; 291 | 292 | if (window[WINDOW_REFRESH_HANDLER]) { 293 | window.removeEventListener("resize", window[WINDOW_REFRESH_HANDLER]); 294 | window.removeEventListener("scroll", window[WINDOW_REFRESH_HANDLER]); 295 | } 296 | window.addEventListener("resize", refreshPickerPosition); 297 | window.addEventListener("scroll", refreshPickerPosition, { passive: true }); 298 | window[WINDOW_REFRESH_HANDLER] = refreshPickerPosition; 299 | 300 | pickerInput.setAttribute("tabindex", "-1"); 301 | pickerInput.setAttribute("aria-hidden", "true"); 302 | pickerInput.inert = true; 303 | 304 | const setPickerBaseColor = (overrideHex) => { 305 | const parsedValues = window.palettes && window.palettes.parseColorValues 306 | ? window.palettes.parseColorValues(colorInput.value) 307 | : []; 308 | const lastHex = parsedValues && parsedValues.length ? parsedValues[parsedValues.length - 1] : null; 309 | const hexToUse = normalizeHex(overrideHex || (lastHex ? `#${lastHex}` : defaultColor)); 310 | const formatted = `#${hexToUse || normalizeHex(defaultColor)}`; 311 | pickerInput.value = formatted; 312 | }; 313 | 314 | const handlePalettePickerKeydown = (event) => { 315 | if (event.defaultPrevented) return; 316 | if (!isActivationKey(event)) return; 317 | const pickerCell = closestFromEvent(event, ".edit-base-button"); 318 | if (!pickerCell) return; 319 | event.preventDefault(); 320 | event.stopPropagation(); 321 | suppressNextPalettePickerOpen = null; 322 | if (isPickerOpen && activePickerCell === pickerCell) { 323 | focusReturnTarget = pickerCell; 324 | suppressTooltipUntilMouseOut(pickerCell); 325 | Coloris.close(); 326 | return; 327 | } 328 | const colorIndex = parseInt(pickerCell.getAttribute("data-color-index"), 10); 329 | const colorHex = normalizeHex(pickerCell.getAttribute("data-color-hex")); 330 | const rowType = pickerCell.getAttribute("data-row-type") || null; 331 | openPicker({ 332 | target: pickerCell, 333 | baseHex: colorHex ? `#${colorHex}` : null, 334 | mode: "edit", 335 | index: colorIndex, 336 | rowType 337 | }); 338 | }; 339 | 340 | const getActiveHex = () => { 341 | const colorValueInput = document.getElementById("clr-color-value"); 342 | const fromColorValue = colorValueInput ? normalizeHex(colorValueInput.value) : ""; 343 | const fromPickerInput = normalizeHex(pickerInput.value); 344 | return pendingHex || fromColorValue || fromPickerInput || ""; 345 | }; 346 | 347 | const triggerPaletteRebuild = (options = {}) => { 348 | const form = document.getElementById("color-entry-form"); 349 | if (form) { 350 | form.dispatchEvent(new CustomEvent("submit", { bubbles: true, cancelable: true, detail: options })); 351 | } 352 | }; 353 | 354 | const normalizePickerInputValue = () => { 355 | const colorValueInput = document.getElementById("clr-color-value"); 356 | if (!colorValueInput) return ""; 357 | const normalized = normalizeHex(colorValueInput.value); 358 | if (!normalized) return ""; 359 | const formatted = `#${normalized}`; 360 | if (colorValueInput.value !== formatted) { 361 | colorValueInput.value = formatted; 362 | colorValueInput.dispatchEvent(new Event("input", { bubbles: true })); 363 | } 364 | return formatted; 365 | }; 366 | 367 | const applyCommittedHex = (hexValue) => { 368 | if (!hexValue || hasCommittedThisSession) return; 369 | const parsed = window.palettes && window.palettes.parseColorValues 370 | ? window.palettes.parseColorValues(colorInput.value) || [] 371 | : []; 372 | 373 | if (activeContext.mode === "edit" && Number.isInteger(activeContext.index)) { 374 | if (activeContext.index < 0 || activeContext.index >= parsed.length) return; 375 | } 376 | 377 | if (activeContext.mode === "edit" && Number.isInteger(activeContext.index)) { 378 | if (!parsed.length) return; 379 | parsed[activeContext.index] = hexValue; 380 | colorInput.value = parsed.join(" "); 381 | } else { 382 | const currentValue = colorInput.value.trim(); 383 | colorInput.value = currentValue ? `${currentValue} ${hexValue}` : hexValue; 384 | } 385 | 386 | pendingHex = ""; 387 | colorInput.dispatchEvent(new Event("input", { bubbles: true })); 388 | if (activeContext.mode === "edit") { 389 | const focusPickerContext = Number.isInteger(activeContext.index) 390 | ? { 391 | colorIndex: activeContext.index, 392 | rowType: activeContext.rowType || null 393 | } 394 | : null; 395 | triggerPaletteRebuild({ skipScroll: true, skipFocus: true, focusPickerContext }); 396 | } 397 | hasCommittedThisSession = true; 398 | Coloris.close(); 399 | }; 400 | 401 | const wireCloseButton = () => { 402 | const closeButton = document.getElementById("clr-close"); 403 | if (!closeButton || closeButton.dataset.hexCloseAttached) return; 404 | closeButton.dataset.hexCloseAttached = "true"; 405 | closeButton.addEventListener("click", (event) => { 406 | event.preventDefault(); 407 | shouldCommit = true; 408 | applyCommittedHex(getActiveHex()); 409 | }); 410 | wireHexInputEnterHandler(); 411 | }; 412 | 413 | const wireHexInputEnterHandler = () => { 414 | const colorValueInput = document.getElementById("clr-color-value"); 415 | if (!colorValueInput) return; 416 | colorValueInput.setAttribute("spellcheck", "false"); 417 | colorValueInput.setAttribute("autocomplete", "off"); 418 | colorValueInput.setAttribute("autocapitalize", "off"); 419 | colorValueInput.setAttribute("autocorrect", "off"); 420 | if (colorValueInput.dataset.hexEnterAttached) return; 421 | colorValueInput.dataset.hexEnterAttached = "true"; 422 | colorValueInput.addEventListener("keydown", (event) => { 423 | if (event.defaultPrevented) return; 424 | if (event.key !== "Enter" && event.key !== "NumpadEnter") return; 425 | event.preventDefault(); 426 | event.stopPropagation(); 427 | normalizePickerInputValue(); 428 | const closeButton = document.getElementById("clr-close"); 429 | focusEl(closeButton || pickerButton); 430 | }); 431 | }; 432 | 433 | const focusPickerTextInput = () => { 434 | const colorValueInput = document.getElementById("clr-color-value"); 435 | if (!colorValueInput) return; 436 | focusEl(colorValueInput); 437 | if (typeof colorValueInput.select === "function") { 438 | colorValueInput.select(); 439 | return; 440 | } 441 | if (typeof colorValueInput.setSelectionRange === "function") { 442 | colorValueInput.setSelectionRange(0, colorValueInput.value.length); 443 | } 444 | }; 445 | 446 | const openPicker = ({ target, baseHex, mode, index, rowType = null }) => { 447 | activeContext = { 448 | mode, 449 | index: Number.isInteger(index) ? index : null, 450 | rowType: rowType || null 451 | }; 452 | hasCommittedThisSession = false; 453 | shouldCommit = false; 454 | focusButtonOnEsc = false; 455 | focusReturnTarget = target || null; 456 | activatePickerCell(target); 457 | positionPickerInput(target || pickerButton); 458 | Coloris.setInstance("#color-picker-input", { themeMode: getThemeMode(), parent: "body" }); 459 | setPickerBaseColor(baseHex); 460 | pendingHex = ""; 461 | isPickerOpen = true; 462 | pendingFocusTarget = null; 463 | setTimeout(wireCloseButton, 0); 464 | setTimeout(focusPickerTextInput, 0); 465 | pickerInput.dispatchEvent(new Event("click", { bubbles: true })); 466 | }; 467 | 468 | Coloris({ 469 | el: "#color-picker-input", 470 | theme: "polaroid", 471 | themeMode: getThemeMode(), 472 | parent: "body", 473 | alpha: false, 474 | format: "hex", 475 | focusInput: true, 476 | selectInput: true, 477 | closeButton: false, 478 | wrap: false, 479 | margin: 6, 480 | defaultColor, 481 | onChange: (color) => { 482 | pendingHex = normalizeHex(color); 483 | } 484 | }); 485 | 486 | if (!pickerInput.dataset.tsBound) { 487 | pickerInput.dataset.tsBound = "true"; 488 | pickerInput.addEventListener("close", () => { 489 | if (shouldCommit && !hasCommittedThisSession) { 490 | applyCommittedHex(pendingHex || getActiveHex()); 491 | } 492 | pendingHex = ""; 493 | shouldCommit = false; 494 | activePickerAnchor = null; 495 | clearActivePickerCell(); 496 | isPickerOpen = false; 497 | if (focusButtonOnEsc) { 498 | focusEl(focusReturnTarget || pickerButton); 499 | focusButtonOnEsc = false; 500 | focusReturnTarget = null; 501 | return; 502 | } 503 | if (pendingFocusTarget) { 504 | const target = pendingFocusTarget; 505 | pendingFocusTarget = null; 506 | setTimeout(() => focusEl(target), 0); 507 | focusReturnTarget = null; 508 | return; 509 | } 510 | if (focusReturnTarget) { 511 | focusEl(focusReturnTarget); 512 | focusReturnTarget = null; 513 | } 514 | }); 515 | } 516 | 517 | if (!pickerButton.dataset.tsBound) { 518 | pickerButton.dataset.tsBound = "true"; 519 | pickerButton.addEventListener("pointerdown", (event) => { 520 | if (!isPickerOpen || activeContext.mode !== "add") return; 521 | suppressNextPickerButtonOpen = true; 522 | focusReturnTarget = pickerButton; 523 | Coloris.close(); 524 | event.preventDefault(); 525 | event.stopPropagation(); 526 | }); 527 | pickerButton.addEventListener("click", () => { 528 | if (suppressNextPickerButtonOpen) { 529 | suppressNextPickerButtonOpen = false; 530 | return; 531 | } 532 | if (isPickerOpen && activeContext.mode === "add") { 533 | focusReturnTarget = pickerButton; 534 | Coloris.close(); 535 | return; 536 | } 537 | openPicker({ target: pickerButton, baseHex: null, mode: "add", index: null }); 538 | }); 539 | 540 | pickerButton.addEventListener("keydown", (event) => { 541 | if (event.defaultPrevented) return; 542 | if (isActivationKey(event)) { 543 | event.preventDefault(); 544 | if (isPickerOpen && activeContext.mode === "add") { 545 | focusReturnTarget = pickerButton; 546 | Coloris.close(); 547 | return; 548 | } 549 | openPicker({ target: pickerButton, baseHex: null, mode: "add", index: null }); 550 | return; 551 | } 552 | if (event.key === "Tab" && !event.shiftKey && submitButton) { 553 | event.preventDefault(); 554 | focusEl(submitButton); 555 | } else if (event.key === "Tab" && event.shiftKey && colorInput) { 556 | event.preventDefault(); 557 | focusEl(colorInput); 558 | } 559 | }); 560 | } 561 | 562 | 563 | const paletteContainer = document.getElementById("tints-and-shades"); 564 | if (paletteContainer && !paletteContainer.dataset.tsPickerKeydownBound) { 565 | paletteContainer.dataset.tsPickerKeydownBound = "true"; 566 | paletteContainer.addEventListener("keydown", handlePalettePickerKeydown, true); 567 | } 568 | 569 | if (submitButton) { 570 | submitButton.addEventListener("keydown", (event) => { 571 | if (event.defaultPrevented) return; 572 | if (event.key === "Tab" && event.shiftKey) { 573 | event.preventDefault(); 574 | focusEl(pickerButton); 575 | } 576 | }); 577 | } 578 | })(); 579 | -------------------------------------------------------------------------------- /src/styles/_main.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as *; 2 | 3 | *, 4 | *:before, 5 | *:after { 6 | box-sizing: border-box; 7 | } 8 | 9 | html { 10 | font-size: 62.5%; 11 | color-scheme: light; 12 | 13 | @include breakpoint('large') { 14 | font-size: 55%; 15 | } 16 | } 17 | 18 | html, 19 | body { 20 | background-color: $white; 21 | transition: background-color 220ms ease; 22 | } 23 | 24 | body { 25 | font-size: 1.8rem; 26 | line-height: 1.618; 27 | font-family: $font-primary; 28 | font-weight: normal; 29 | text-align: center; 30 | color: $black; 31 | 32 | &.modal-open { 33 | overflow: hidden; 34 | position: fixed; 35 | width: 100%; 36 | } 37 | } 38 | 39 | a { 40 | color: $black; 41 | text-decoration: none; 42 | border-bottom: 3px solid $black; 43 | 44 | &:hover { 45 | border-color: $magenta; 46 | } 47 | } 48 | 49 | .header { 50 | display: flex; 51 | justify-content: center; 52 | padding: 7.5rem 2rem 8rem; 53 | margin: auto; 54 | 55 | @at-root body:has(.docs) & { 56 | padding-bottom: 6rem; 57 | } 58 | 59 | @include breakpoint('large') { 60 | padding: 9rem 2rem 6rem; 61 | } 62 | 63 | @include breakpoint('small') { 64 | padding-bottom: 4rem; 65 | } 66 | 67 | .preface { 68 | position: absolute; 69 | top: 0; 70 | display: flex; 71 | justify-content: space-between; 72 | width: 100%; 73 | left: 0; 74 | 75 | .dark-mode-selector { 76 | position: relative; 77 | top: 1.5rem; 78 | left: 2rem; 79 | display: grid; 80 | height: 4.8rem; 81 | width: 4.8rem; 82 | 83 | @include breakpoint('large') { 84 | top: 2rem; 85 | } 86 | 87 | .theme-toggle { 88 | background-color: $gray-50; 89 | border: none; 90 | cursor: pointer; 91 | display: grid; 92 | padding: 0; 93 | border-radius: 50%; 94 | 95 | &:hover { 96 | background-color: $gray-100; 97 | } 98 | 99 | &:active { 100 | background-color: color-mix(in srgb, $gray-100 92%, $black); 101 | } 102 | 103 | &-icon { 104 | display: inline-flex; 105 | align-items: center; 106 | justify-content: center; 107 | 108 | svg { 109 | width: 2.2rem; 110 | height: 2.2rem; 111 | color: $black; 112 | } 113 | 114 | .icon-tabler-sun-high { 115 | display: none; 116 | } 117 | 118 | .icon-tabler-moon { 119 | display: inline-block; 120 | } 121 | } 122 | } 123 | } 124 | 125 | .announcement { 126 | position: absolute; 127 | left: 50%; 128 | top: 2.2rem; 129 | transform: translateX(-50%); 130 | padding: .5rem 2rem; 131 | border-radius: 999px; 132 | background-color: $gray-50; 133 | color: $black; 134 | font-size: 1.3rem; 135 | display: inline-flex; 136 | align-items: center; 137 | border: none; 138 | font-weight: 500; 139 | gap: .3rem; 140 | transition: none; 141 | 142 | @include breakpoint('large') { 143 | top: 3rem; 144 | } 145 | 146 | &:hover { 147 | background-color: $gray-100; 148 | } 149 | 150 | &:active { 151 | background-color: color-mix(in srgb, $gray-100 92%, $black); 152 | } 153 | 154 | &-header { 155 | text-transform: uppercase; 156 | } 157 | 158 | &-icon { 159 | display: flex; 160 | 161 | svg { 162 | width: 2rem; 163 | height: 2rem; 164 | } 165 | } 166 | } 167 | 168 | a.github-corner { 169 | border: none; 170 | display: flex; 171 | fill: $black; 172 | color: $white; 173 | clip-path: polygon(0 0, 100% 0, 100% 100%); 174 | outline-offset: -1px; 175 | 176 | &:focus-visible { 177 | clip-path: none; 178 | } 179 | } 180 | } 181 | 182 | h1 { 183 | margin: 0; 184 | position: relative; 185 | z-index: 1; 186 | 187 | a { 188 | background-image: linear-gradient(to right, $orange, $magenta); 189 | border-radius: $radius-main; 190 | margin-bottom: 0; 191 | font-size: 5.6rem; 192 | font-weight: 900; 193 | line-height: 1.1; 194 | color: $white; 195 | text-decoration: none; 196 | border: none; 197 | padding: 1rem 3rem; 198 | display: flex; 199 | justify-content: center; 200 | width: 72rem; 201 | 202 | @include breakpoint('large') { 203 | width: 100%; 204 | } 205 | 206 | @include breakpoint('medium') { 207 | font-size: 4.8rem; 208 | padding: 1rem 1.5rem; 209 | } 210 | 211 | @include breakpoint('small') { 212 | font-size: 3.6rem; 213 | } 214 | } 215 | } 216 | } 217 | 218 | .main { 219 | margin: 0 auto 10rem; 220 | padding: 0 2rem; 221 | max-width: 85rem; 222 | } 223 | 224 | .form { 225 | 226 | form { 227 | display: flex; 228 | flex-direction: column; 229 | align-items: center; 230 | justify-content: center; 231 | } 232 | 233 | &-labels { 234 | position: relative; 235 | display: flex; 236 | justify-content: center; 237 | 238 | .textarea-label { 239 | font-weight: normal; 240 | font-size: 2.4rem; 241 | line-height: 1.3; 242 | 243 | @include breakpoint('small') { 244 | font-size: 2rem; 245 | } 246 | } 247 | 248 | .form-warning { 249 | opacity: 0; 250 | visibility: hidden; 251 | background-color: $alert; 252 | color: $white; 253 | position: absolute; 254 | bottom: -1rem; 255 | font-size: 2rem; 256 | line-height: 2.4rem; 257 | display: block; 258 | padding: .75rem 1.5rem; 259 | border-radius: $radius-main; 260 | transition: opacity 200ms linear, bottom 250ms ease-out, visibility 0s linear 250ms; 261 | z-index: 1; 262 | 263 | &:after { 264 | content: ""; 265 | position: absolute; 266 | left: calc(50% - 1rem); 267 | margin-top: 1.5rem; 268 | width: 2rem; 269 | height: 2rem; 270 | transform: rotate(45deg); 271 | background-color: $alert; 272 | z-index: -1; 273 | } 274 | 275 | &.visible { 276 | opacity: 1; 277 | visibility: visible; 278 | bottom: .2rem; 279 | transition: opacity 200ms linear, bottom 250ms ease-out, visibility 0s; 280 | } 281 | } 282 | } 283 | 284 | &-input { 285 | display: flex; 286 | max-width: 54rem; 287 | margin: 1rem auto 2rem; 288 | width: 100%; 289 | position: relative; 290 | 291 | textarea { 292 | padding: 1.6rem; 293 | min-height: 24rem; 294 | background-color: $gray-50; 295 | border: 1px solid $gray-200; 296 | word-spacing: .25rem; 297 | border-radius: $radius-main; 298 | resize: none; 299 | width: 100%; 300 | font-family: $font-monospace; 301 | 302 | @media (pointer: coarse) { 303 | font-size: 16px; 304 | } 305 | } 306 | } 307 | 308 | .make-button { 309 | padding: 1.6rem; 310 | width: 100%; 311 | max-width: 54rem; 312 | border: none; 313 | background-color: $black; 314 | color: $white; 315 | border-radius: $radius-main; 316 | line-height: inherit; 317 | cursor: pointer; 318 | font-size: 2rem; 319 | font-weight: 400; 320 | transition: background-color 150ms ease-in-out; 321 | 322 | &:hover { 323 | background-color: $magenta; 324 | } 325 | 326 | &:active { 327 | background-color: color-mix(in srgb, $magenta 85%, $white); 328 | } 329 | } 330 | 331 | .color-picker-wrapper { 332 | position: absolute; 333 | right: 1.2rem; 334 | bottom: 1.2rem; 335 | z-index: 2; 336 | width: 4.8rem; 337 | height: 4.8rem; 338 | 339 | .color-picker-button { 340 | width: 100%; 341 | height: 100%; 342 | display: inline-flex; 343 | align-items: center; 344 | justify-content: center; 345 | padding: 0; 346 | border: none; 347 | border-radius: 50%; 348 | background-color: $white; 349 | color: $black; 350 | box-shadow: $shadow-main; 351 | cursor: pointer; 352 | transition: opacity 150ms ease-in-out, transform 150ms ease-in-out; 353 | 354 | svg { 355 | width: 2.2rem; 356 | height: 2.2rem; 357 | display: flex; 358 | } 359 | 360 | &:active { 361 | transform: scale(.9); 362 | opacity: .75; 363 | } 364 | } 365 | 366 | .color-picker-input { 367 | position: absolute; 368 | inset: 0; 369 | opacity: 0; 370 | pointer-events: none; 371 | border: none; 372 | padding: 0; 373 | } 374 | } 375 | } 376 | 377 | .copy-indicator { 378 | display: grid; 379 | place-items: center; 380 | position: absolute; 381 | top: 50%; 382 | left: 50%; 383 | pointer-events: none; 384 | transition: opacity 150ms ease-in-out, transform 150ms ease-in-out; 385 | will-change: opacity, transform; 386 | color: $black; 387 | 388 | svg { 389 | width: 2rem; 390 | height: 2rem; 391 | } 392 | 393 | &-check { 394 | color: $success; 395 | 396 | svg { 397 | width: 2.8rem; 398 | height: 2.8rem; 399 | } 400 | } 401 | } 402 | 403 | [data-tooltip] { 404 | position: relative; 405 | overflow: visible; 406 | } 407 | 408 | @media (hover: hover) and (pointer: fine) { 409 | [data-tooltip]::after { 410 | content: attr(data-tooltip); 411 | position: absolute; 412 | bottom: calc(100% + .6rem); 413 | left: 50%; 414 | background-color: $gray-600; 415 | color: $white; 416 | padding: .6rem 1.2rem; 417 | border-radius: $radius-main; 418 | font-size: 1.4rem; 419 | font-weight: 400; 420 | line-height: 1.4; 421 | z-index: 30; 422 | pointer-events: none; 423 | will-change: opacity, transform; 424 | max-width: 20rem; 425 | width: max-content; 426 | white-space: normal; 427 | overflow-wrap: anywhere; 428 | opacity: 0; 429 | transform: translate(-50%, .5rem); 430 | transition: opacity 150ms ease, transform 150ms ease; 431 | transition-delay: 0ms; 432 | } 433 | 434 | [data-tooltip]:hover::after { 435 | opacity: 1; 436 | transform: translate(-50%, 0); 437 | transition-delay: 500ms; 438 | } 439 | 440 | [data-tooltip]:focus-visible::after { 441 | opacity: 1; 442 | transform: translate(-50%, 0); 443 | transition: none; 444 | transition-delay: 0ms; 445 | } 446 | 447 | [data-tooltip][data-tooltip-immediate]::after { 448 | transition: none; 449 | transition-delay: 0ms; 450 | } 451 | 452 | [data-tooltip][data-tooltip-suppressed]::after { 453 | display: none; 454 | } 455 | } 456 | 457 | @include breakpoint('large') { 458 | [data-tooltip]::after { 459 | display: none; 460 | } 461 | } 462 | 463 | .edit-base-button { 464 | &.is-picker-open[data-tooltip]::after { 465 | display: none; 466 | } 467 | } 468 | 469 | #clr-picker { 470 | box-shadow: $shadow-main; 471 | 472 | &:before { 473 | color: transparent; 474 | } 475 | 476 | input.clr-color { 477 | border-color: $gray-200; 478 | background-color: $gray-50; 479 | font-family: $font-monospace; 480 | } 481 | } 482 | 483 | .palettes { 484 | margin-top: 6rem; 485 | 486 | .palette-controls-wrapper { 487 | margin-bottom: 2.8rem; 488 | opacity: 1; 489 | 490 | &[hidden] { 491 | display: none !important; 492 | } 493 | 494 | .palette-controls { 495 | display: flex; 496 | flex-wrap: wrap; 497 | justify-content: space-between; 498 | gap: 1.2rem; 499 | 500 | .step-selector { 501 | display: flex; 502 | 503 | &-option { 504 | border-color: transparent; 505 | width: 4.5rem; 506 | background-color: $gray-50; 507 | color: $black; 508 | padding: 0; 509 | font-size: 1.6rem; 510 | font-weight: 500; 511 | cursor: pointer; 512 | 513 | @include breakpoint('small') { 514 | width: 4rem; 515 | } 516 | 517 | &:hover { 518 | background-color: $gray-100; 519 | } 520 | 521 | &:active { 522 | background-color: color-mix(in srgb, $gray-100 92%, $black); 523 | } 524 | 525 | &.is-active { 526 | background-color: $gray-200; 527 | 528 | &:active { 529 | background-color: color-mix(in srgb, $gray-200 92%, $black); 530 | } 531 | } 532 | 533 | &:first-child { 534 | border-radius: $radius-main 0 0 $radius-main; 535 | } 536 | 537 | &:last-child { 538 | border-radius: 0 $radius-main $radius-main 0; 539 | } 540 | } 541 | } 542 | 543 | .inline-actions { 544 | display: flex; 545 | gap: 1.2rem; 546 | 547 | @include breakpoint('small') { 548 | gap: .8rem; 549 | } 550 | } 551 | 552 | .external-actions { 553 | display: inline-flex; 554 | gap: 1.2rem; 555 | 556 | @include breakpoint('small') { 557 | gap: .8rem; 558 | } 559 | } 560 | 561 | .action-button { 562 | border: none; 563 | border-radius: $radius-main; 564 | padding: 1rem; 565 | cursor: pointer; 566 | background-color: $gray-50; 567 | color: $black; 568 | display: inline-flex; 569 | 570 | @include breakpoint('small') { 571 | padding: .75rem; 572 | } 573 | 574 | .icon { 575 | width: 2rem; 576 | height: 2rem; 577 | } 578 | 579 | &:hover { 580 | background-color: $gray-100; 581 | } 582 | 583 | &:active { 584 | background-color: color-mix(in srgb, $gray-100 92%, $black); 585 | } 586 | 587 | &.is-active { 588 | background-color: $gray-200; 589 | 590 | &:active { 591 | background-color: color-mix(in srgb, $gray-200 92%, $black); 592 | } 593 | } 594 | } 595 | } 596 | } 597 | 598 | #tints-and-shades { 599 | overflow-x: visible; 600 | width: 100%; 601 | outline: 0; 602 | 603 | .palette-wrapper { 604 | margin-bottom: 2rem; 605 | transition: opacity 220ms ease, margin-bottom 220ms ease, height 220ms ease, padding-top 220ms ease, padding-bottom 220ms ease; 606 | will-change: opacity, margin-bottom, height, padding-top, padding-bottom; 607 | 608 | &[data-entering="true"] { 609 | opacity: 0; 610 | visibility: hidden; 611 | } 612 | 613 | &-fading { 614 | opacity: 0; 615 | pointer-events: none; 616 | } 617 | 618 | &-collapsing { 619 | pointer-events: none; 620 | } 621 | 622 | .palette-titlebar { 623 | display: flex; 624 | align-items: center; 625 | justify-content: space-between; 626 | margin-bottom: 1.8rem; 627 | text-align: left; 628 | 629 | &-controls { 630 | display: flex; 631 | align-items: center; 632 | gap: 1.2rem; 633 | } 634 | 635 | &-name { 636 | font-size: 1.4rem; 637 | line-height: normal; 638 | font-weight: 500; 639 | color: $black; 640 | flex: 1 1 0; 641 | min-width: 0; 642 | margin-right: 1.2rem; 643 | overflow: hidden; 644 | text-overflow: ellipsis; 645 | white-space: nowrap; 646 | } 647 | 648 | &-action { 649 | border: none; 650 | background-color: transparent; 651 | cursor: pointer; 652 | display: inline-flex; 653 | color: $black; 654 | padding: 0; 655 | 656 | svg { 657 | width: 2rem; 658 | height: 2rem; 659 | } 660 | } 661 | } 662 | 663 | .palette-complement-dropdown { 664 | position: relative; 665 | display: inline-flex; 666 | 667 | &-toggle { 668 | position: relative; 669 | z-index: 2; 670 | } 671 | 672 | &-menu { 673 | display: none; 674 | position: absolute; 675 | top: calc(100% + .4rem); 676 | right: -1rem; 677 | z-index: 20; 678 | background-color: $white; 679 | border-radius: $radius-main; 680 | box-shadow: $shadow-main; 681 | white-space: nowrap; 682 | } 683 | 684 | &.is-open { 685 | .palette-complement-dropdown-menu { 686 | display: flex; 687 | flex-direction: column; 688 | } 689 | } 690 | 691 | &-item { 692 | border: none; 693 | background-color: transparent; 694 | color: $black; 695 | font-size: 1.4rem; 696 | line-height: 1.4; 697 | text-align: left; 698 | width: 100%; 699 | padding: .8rem 1.2rem; 700 | cursor: pointer; 701 | overflow: hidden; 702 | 703 | &:is(:hover, :focus-visible) { 704 | background-color: $gray-100; 705 | } 706 | 707 | &:active { 708 | background-color: color-mix(in srgb, $gray-100 92%, $black); 709 | } 710 | 711 | &:first-child { 712 | border-top-left-radius: $radius-main; 713 | border-top-right-radius: $radius-main; 714 | } 715 | 716 | &:last-child { 717 | border-bottom-left-radius: $radius-main; 718 | border-bottom-right-radius: $radius-main; 719 | } 720 | } 721 | } 722 | 723 | .palette-table { 724 | overflow-x: auto; 725 | 726 | table { 727 | width: max-content; 728 | min-width: 100%; 729 | border-collapse: collapse; 730 | 731 | .table-header td { 732 | font-size: 1.4rem; 733 | min-width: 6.5rem; 734 | padding: 0 0 .5rem; 735 | font-weight: 500; 736 | line-height: normal; 737 | } 738 | 739 | td.hex-value { 740 | text-align: center; 741 | padding: .5rem 0 2rem; 742 | text-transform: lowercase; 743 | line-height: normal; 744 | font-family: $font-monospace; 745 | 746 | code { 747 | font-size: 1.3rem; 748 | } 749 | } 750 | 751 | td.hex-color { 752 | height: 6.5rem; 753 | min-width: 7.5rem; 754 | cursor: pointer; 755 | position: relative; 756 | outline: 0; 757 | overflow: hidden; 758 | 759 | &.copy-locked { 760 | pointer-events: none; 761 | } 762 | 763 | .copy-indicator { 764 | height: 4rem; 765 | width: 4rem; 766 | opacity: 0; 767 | border-radius: $radius-main; 768 | box-shadow: $shadow-main; 769 | background-color: $white; 770 | transform: translate(-50%, -45%); 771 | 772 | &-check { 773 | transform: translate(-50%, -50%); 774 | opacity: 0; 775 | transition: opacity 150ms ease; 776 | } 777 | } 778 | 779 | &:is(:hover, :focus-visible) .copy-indicator-copy { 780 | opacity: 1; 781 | transform: translate(-50%, -50%); 782 | } 783 | 784 | &:not(.copied):active .copy-indicator-copy { 785 | opacity: .75; 786 | transform: translate(-50%, -50%) scale(.9); 787 | } 788 | 789 | &.copied { 790 | .copy-indicator-check { 791 | opacity: 1; 792 | } 793 | 794 | .copy-indicator-copy { 795 | opacity: 0; 796 | } 797 | } 798 | 799 | @media (pointer: coarse) { 800 | .copy-indicator-copy { 801 | display: none; 802 | } 803 | } 804 | } 805 | } 806 | } 807 | } 808 | } 809 | } 810 | 811 | .utility-dialog { 812 | position: relative; 813 | width: min(50rem, 100vw); 814 | max-height: 60rem; 815 | border: 1px solid $gray-200; 816 | border-radius: $radius-main; 817 | padding: 1.6rem; 818 | box-shadow: 0 20px 70px rgba(0, 0, 0, .25); 819 | background-color: $white; 820 | color: $black; 821 | overflow: hidden; 822 | transform-origin: center; 823 | 824 | &-header { 825 | display: flex; 826 | align-items: center; 827 | justify-content: space-between; 828 | margin-bottom: 2rem; 829 | padding: .5rem 0; 830 | } 831 | 832 | &-title { 833 | margin: 0; 834 | font-size: 2rem; 835 | font-weight: 500; 836 | } 837 | 838 | &-close { 839 | border: none; 840 | background-color: transparent; 841 | cursor: pointer; 842 | color: $black; 843 | padding: 0; 844 | 845 | span { 846 | display: flex; 847 | } 848 | 849 | svg { 850 | width: 2.8rem; 851 | height: 2.8rem; 852 | } 853 | } 854 | 855 | .utility-copy-button { 856 | width: 4.8rem; 857 | height: 4.8rem; 858 | padding: 0; 859 | border-radius: 50%; 860 | border: none; 861 | background-color: $white; 862 | color: $black; 863 | cursor: pointer; 864 | box-shadow: $shadow-main; 865 | position: relative; 866 | transition: background-color 150ms ease, transform 150ms ease, box-shadow 150ms ease; 867 | 868 | .copy-indicator-copy { 869 | opacity: 1; 870 | transform: translate(-50%, -50%) scale(1); 871 | } 872 | 873 | .copy-indicator-check { 874 | opacity: 0; 875 | transform: translate(-50%, -50%) scale(1.2); 876 | } 877 | 878 | &:active { 879 | opacity: .75; 880 | transform: scale(.9); 881 | } 882 | 883 | &[disabled] { 884 | pointer-events: none; 885 | } 886 | 887 | &.copied { 888 | .copy-indicator-copy { 889 | opacity: 0; 890 | transform: translate(-50%, -50%) scale(.8); 891 | } 892 | 893 | .copy-indicator-check { 894 | opacity: 1; 895 | transform: translate(-50%, -50%) scale(1); 896 | } 897 | } 898 | } 899 | 900 | &:not([open]) { 901 | display: none; 902 | } 903 | 904 | &[open] { 905 | display: flex; 906 | flex-direction: column; 907 | height: 90vh; 908 | } 909 | 910 | &::backdrop { 911 | background-color: rgba(0, 0, 0, .5); 912 | opacity: 1; 913 | } 914 | 915 | &.is-opening { 916 | animation: export-dialog-fade 180ms ease-out forwards; 917 | 918 | &::backdrop { 919 | animation: export-backdrop-fade 180ms ease-out forwards; 920 | } 921 | } 922 | 923 | &.is-closing { 924 | animation: export-dialog-fade 150ms ease-in reverse forwards; 925 | 926 | &::backdrop { 927 | animation: export-backdrop-fade 150ms ease-in reverse forwards; 928 | } 929 | } 930 | 931 | &.export-dialog { 932 | .export-tabs { 933 | display: flex; 934 | z-index: 1; 935 | 936 | .export-tab { 937 | background-color: transparent; 938 | color: $gray-400; 939 | padding: .8rem 1.6rem; 940 | cursor: pointer; 941 | font-weight: 500; 942 | border: 1px solid transparent; 943 | border-radius: $radius-main $radius-main 0 0; 944 | border-bottom: none; 945 | font-size: 1.6rem; 946 | user-select: none; 947 | 948 | &:hover { 949 | color: $black; 950 | } 951 | 952 | &.is-active { 953 | color: $black; 954 | background-color: $gray-50; 955 | } 956 | } 957 | } 958 | 959 | .export-body { 960 | display: flex; 961 | flex: 1 1 auto; 962 | min-height: 0; 963 | 964 | .export-output { 965 | margin: 0; 966 | padding: 1.8rem; 967 | flex: 1 1 auto; 968 | border-radius: $radius-main; 969 | background-color: $gray-50; 970 | 971 | &.is-first-tab-active { 972 | border-radius: 0 $radius-main $radius-main $radius-main; 973 | } 974 | 975 | &-code { 976 | display: block; 977 | width: 100%; 978 | text-shadow: none; 979 | font-size: 1.4rem; 980 | font-family: $font-monospace; 981 | color: $black; 982 | margin: -2.7rem 0 -5.4rem; 983 | 984 | html:not(.darkmode-active) & { 985 | 986 | .token.selector, 987 | .token.string { 988 | color: #4f7300; 989 | } 990 | 991 | .token.comment { 992 | color: #5e6f80; 993 | } 994 | 995 | .token.operator { 996 | background-color: transparent; 997 | } 998 | } 999 | } 1000 | } 1001 | } 1002 | 1003 | .export-copy { 1004 | position: absolute; 1005 | right: 3rem; 1006 | bottom: 3rem; 1007 | display: flex; 1008 | } 1009 | } 1010 | 1011 | &.share-dialog { 1012 | .share-body { 1013 | display: flex; 1014 | flex-direction: column; 1015 | align-items: flex-end; 1016 | gap: 1.5rem; 1017 | margin-bottom: .5rem; 1018 | 1019 | .share-input { 1020 | width: 100%; 1021 | font-size: 1.4rem; 1022 | line-height: 1.5; 1023 | padding: 1.2rem 1.4rem; 1024 | border-radius: $radius-main; 1025 | border: none; 1026 | background-color: $gray-50; 1027 | font-family: $font-monospace; 1028 | resize: none; 1029 | min-height: 4.5rem; 1030 | color: $black; 1031 | } 1032 | } 1033 | 1034 | &[open] { 1035 | height: fit-content; 1036 | } 1037 | } 1038 | } 1039 | 1040 | .footer ul { 1041 | list-style: none; 1042 | display: flex; 1043 | gap: 4rem; 1044 | justify-content: center; 1045 | padding: 0; 1046 | margin: 0 0 8rem; 1047 | } 1048 | 1049 | .sr-only, 1050 | .skip-link { 1051 | position: absolute; 1052 | width: 1px; 1053 | height: 1px; 1054 | padding: 0; 1055 | margin: -1px; 1056 | overflow: hidden; 1057 | clip-path: inset(50%); 1058 | white-space: nowrap; 1059 | border: 0; 1060 | } 1061 | 1062 | .skip-link { 1063 | &:focus-visible { 1064 | top: 1rem; 1065 | left: 1rem; 1066 | width: auto; 1067 | height: auto; 1068 | margin: 0; 1069 | overflow: visible; 1070 | clip-path: none; 1071 | white-space: normal; 1072 | display: inline-block; 1073 | padding: 1rem 2rem; 1074 | border-radius: $radius-main; 1075 | color: $white; 1076 | background-color: $black; 1077 | z-index: 1; 1078 | transition: none; 1079 | } 1080 | } 1081 | 1082 | @keyframes export-dialog-fade { 1083 | from { 1084 | opacity: 0; 1085 | transform: translateY(12px) scale(.98); 1086 | } 1087 | 1088 | to { 1089 | opacity: 1; 1090 | transform: translateY(0) scale(1); 1091 | } 1092 | } 1093 | 1094 | @keyframes export-backdrop-fade { 1095 | from { 1096 | opacity: 0; 1097 | } 1098 | 1099 | to { 1100 | opacity: 1; 1101 | } 1102 | } 1103 | 1104 | @media (prefers-reduced-motion: reduce) { 1105 | 1106 | .utility-dialog.is-opening, 1107 | .utility-dialog.is-closing, 1108 | .utility-dialog.is-opening::backdrop, 1109 | .utility-dialog.is-closing::backdrop { 1110 | animation: none; 1111 | } 1112 | } 1113 | 1114 | .docs { 1115 | text-align: left; 1116 | 1117 | h2 { 1118 | margin: 3.6rem 0 0; 1119 | font-size: 2.7rem; 1120 | line-height: 1.3; 1121 | font-weight: bold; 1122 | 1123 | &:first-child { 1124 | margin-top: 0; 1125 | } 1126 | } 1127 | 1128 | h3 { 1129 | margin: 2rem 0 -.5rem; 1130 | } 1131 | 1132 | p, 1133 | ul, 1134 | ol { 1135 | margin: 1.5rem 0; 1136 | } 1137 | 1138 | li { 1139 | margin-bottom: .5rem; 1140 | } 1141 | 1142 | code { 1143 | background-color: $gray-100; 1144 | font-size: 90%; 1145 | padding: .2rem .6rem; 1146 | border-radius: $radius-main; 1147 | font-family: $font-monospace; 1148 | } 1149 | 1150 | .anchorjs-link { 1151 | border: none; 1152 | 1153 | &:hover { 1154 | color: $magenta; 1155 | } 1156 | } 1157 | 1158 | .subheader-icon { 1159 | vertical-align: middle; 1160 | 1161 | svg { 1162 | width: 2.4rem; 1163 | height: 2.4rem; 1164 | } 1165 | } 1166 | } 1167 | 1168 | .not-found { 1169 | background-color: $gray-50; 1170 | color: $black; 1171 | display: inline-block; 1172 | padding: 4rem 8rem; 1173 | border-radius: $radius-main; 1174 | 1175 | h2 { 1176 | font-weight: 900; 1177 | font-size: 6rem; 1178 | line-height: 1; 1179 | margin: 0; 1180 | } 1181 | } -------------------------------------------------------------------------------- /src/js/export-ui.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const VALID_EXPORT_FORMATS = ["hex", "hex-hash", "rgb", "css", "json"]; 3 | const EXPORT_FORMAT_STORAGE_KEY = "export-preferred-format"; 4 | 5 | const getStoredExportFormat = () => { 6 | try { 7 | const storedFormat = localStorage.getItem(EXPORT_FORMAT_STORAGE_KEY); 8 | if (storedFormat && VALID_EXPORT_FORMATS.includes(storedFormat)) { 9 | return storedFormat; 10 | } 11 | } catch (err) { 12 | // Ignore storage errors and fall back to default 13 | } 14 | return "hex"; 15 | }; 16 | 17 | const persistExportFormat = (format) => { 18 | if (!VALID_EXPORT_FORMATS.includes(format)) return; 19 | try { 20 | localStorage.setItem(EXPORT_FORMAT_STORAGE_KEY, format); 21 | } catch (err) { 22 | // Ignore storage errors 23 | } 24 | }; 25 | 26 | const exportState = { 27 | palettes: [], 28 | format: getStoredExportFormat() 29 | }; 30 | 31 | const exportElements = { 32 | wrapper: null, 33 | openButton: null, 34 | modal: null, 35 | closeButton: null, 36 | tabs: [], 37 | output: null, 38 | code: null, 39 | copyFab: null, 40 | copyStatus: null, 41 | imageButton: null, 42 | }; 43 | 44 | const LANGUAGE_CLASSES = [ 45 | "language-none", 46 | "language-css", 47 | "language-json", 48 | "language-javascript", 49 | "language-markup" 50 | ]; 51 | 52 | const getLanguageClassForFormat = (format) => { 53 | switch (format) { 54 | case "css": 55 | return "language-css"; 56 | case "json": 57 | return "language-json"; 58 | case "js": 59 | return "language-javascript"; 60 | case "html": 61 | return "language-markup"; 62 | default: 63 | return "language-none"; 64 | } 65 | }; 66 | 67 | const updateExportLanguage = (format, elements) => { 68 | if (!elements.output) return; 69 | const languageClass = getLanguageClassForFormat(format); 70 | LANGUAGE_CLASSES.forEach((className) => { 71 | elements.output.classList.remove(className); 72 | if (elements.code) { 73 | elements.code.classList.remove(className); 74 | } 75 | }); 76 | elements.output.classList.add(languageClass); 77 | if (elements.code) { 78 | elements.code.classList.add(languageClass); 79 | } 80 | }; 81 | 82 | const highlightExportCode = (codeElement) => { 83 | if (!codeElement || typeof window === "undefined") return; 84 | const prism = window.Prism; 85 | if (!prism || typeof prism.highlightElement !== "function") return; 86 | prism.highlightElement(codeElement); 87 | }; 88 | 89 | let pageScrollY = 0; 90 | let handleOutsidePointerDown = null; 91 | 92 | const clampPercent = (percent) => { 93 | if (typeof percent !== "number" || Number.isNaN(percent)) return 0; 94 | return Math.min(Math.max(percent, 0), 100); 95 | }; 96 | 97 | const formatCssTier = (percent) => { 98 | const safe = clampPercent(percent); 99 | return Math.round(safe * 10).toString(); // use 100s scale (10% => 100) 100 | }; 101 | 102 | const prefersReducedMotion = () => { 103 | return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; 104 | }; 105 | 106 | const canvasToBlob = (canvas) => new Promise((resolve) => { 107 | const serialize = () => { 108 | const dataUrl = canvas.toDataURL("image/png"); 109 | const base64 = dataUrl.split(",")[1] || ""; 110 | const binary = atob(base64); 111 | const buffer = new Uint8Array(binary.length); 112 | for (let i = 0; i < binary.length; i += 1) { 113 | buffer[i] = binary.charCodeAt(i); 114 | } 115 | resolve(new Blob([buffer], { type: "image/png" })); 116 | }; 117 | 118 | if (canvas.toBlob) { 119 | canvas.toBlob((blob) => { 120 | if (blob) { 121 | resolve(blob); 122 | return; 123 | } 124 | serialize(); 125 | }); 126 | return; 127 | } 128 | serialize(); 129 | }); 130 | 131 | const downloadBlob = (blob, filename) => { 132 | if (!blob) return; 133 | const url = URL.createObjectURL(blob); 134 | const link = document.createElement("a"); 135 | link.href = url; 136 | link.download = filename; 137 | document.body.appendChild(link); 138 | link.click(); 139 | link.remove(); 140 | setTimeout(() => URL.revokeObjectURL(url), 1000); 141 | }; 142 | 143 | function createTableCanvas(table) { 144 | if (!table) return null; 145 | 146 | const rows = Array.from(table.rows || []); 147 | if (!rows.length) return null; 148 | 149 | const tableRect = table.getBoundingClientRect(); 150 | const tableStyle = window.getComputedStyle(table); 151 | 152 | const totalWidth = Math.max(1, Math.round(tableRect.width)); 153 | const totalHeight = Math.max(1, Math.round(tableRect.height)); 154 | if (!totalWidth || !totalHeight) return null; 155 | 156 | const paddingX = 24; 157 | const paddingY = 16; 158 | const bottomTrim = 12; 159 | 160 | const ratio = Math.max(1, window.devicePixelRatio || 1); 161 | const canvasWidthCss = totalWidth + paddingX * 2; 162 | const canvasHeightCss = totalHeight + paddingY * 2 - bottomTrim; 163 | 164 | const canvas = document.createElement("canvas"); 165 | canvas.width = Math.max(1, Math.round(canvasWidthCss * ratio)); 166 | canvas.height = Math.max(1, Math.round(canvasHeightCss * ratio)); 167 | 168 | const ctx = canvas.getContext("2d"); 169 | if (!ctx) return null; 170 | 171 | ctx.scale(ratio, ratio); 172 | 173 | const rootBackground = window.getComputedStyle(document.documentElement).backgroundColor; 174 | const isTransparent = (value) => 175 | !value || value === "transparent" || value === "rgba(0, 0, 0, 0)"; 176 | 177 | let tableBackground = tableStyle.backgroundColor; 178 | if (isTransparent(tableBackground)) { 179 | tableBackground = !isTransparent(rootBackground) ? rootBackground : "#fff"; 180 | } 181 | 182 | ctx.fillStyle = tableBackground; 183 | ctx.fillRect(0, 0, canvasWidthCss, canvasHeightCss); 184 | 185 | ctx.textBaseline = "top"; 186 | 187 | rows.forEach((row) => { 188 | const rowRect = row.getBoundingClientRect(); 189 | const rowTop = Math.round(rowRect.top - tableRect.top); 190 | const rowHeight = Math.max(1, Math.round(rowRect.height)); 191 | 192 | const cells = Array.from(row.cells); 193 | if (!cells.length) return; 194 | 195 | const cellRects = cells.map((cell) => cell.getBoundingClientRect()); 196 | const edges = cellRects.map((r) => ({ 197 | left: r.left - tableRect.left, 198 | right: r.right - tableRect.left 199 | })); 200 | 201 | const intEdges = edges.map((edge, index) => { 202 | const left = Math.round(edge.left); 203 | let right = Math.round(edge.right); 204 | if (index === edges.length - 1) right = totalWidth; 205 | return { left, right, width: Math.max(1, right - left) }; 206 | }); 207 | 208 | cells.forEach((cell, index) => { 209 | const { left, width } = intEdges[index]; 210 | const x = paddingX + left; 211 | const y = paddingY + rowTop; 212 | 213 | const computed = window.getComputedStyle(cell); 214 | const cellBg = computed.backgroundColor; 215 | 216 | if (!isTransparent(cellBg)) { 217 | ctx.fillStyle = cellBg; 218 | } else { 219 | ctx.fillStyle = tableBackground; 220 | } 221 | const isLastCell = index === cells.length - 1; 222 | ctx.fillRect( 223 | x, 224 | y, 225 | isLastCell ? width : width + 2, 226 | rowHeight 227 | ); 228 | 229 | const rawText = (cell.textContent || "").trim(); 230 | const hexPattern = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; 231 | let text = rawText; 232 | if (hexPattern.test(rawText)) { 233 | text = rawText.toLowerCase(); 234 | } 235 | if (!text) return; 236 | 237 | ctx.fillStyle = computed.color || "#000"; 238 | const fontSize = 14; 239 | const fontFamily = computed.fontFamily || "Work Sans, system-ui, sans-serif"; 240 | ctx.font = `${fontSize}px ${fontFamily}`; 241 | 242 | const isHeaderRow = row.classList && row.classList.contains("table-header"); 243 | const isNameRow = row.classList && row.classList.contains("palette-titlebar"); 244 | const titleOffset = isHeaderRow ? 8 : 8; 245 | 246 | const textAlignValue = computed.textAlign || "left"; 247 | const direction = computed.direction || "ltr"; 248 | let normalizedAlign = textAlignValue; 249 | 250 | if (textAlignValue === "start") { 251 | normalizedAlign = direction === "rtl" ? "right" : "left"; 252 | } else if (textAlignValue === "end") { 253 | normalizedAlign = direction === "rtl" ? "left" : "right"; 254 | } 255 | 256 | if (isNameRow) { 257 | normalizedAlign = "left"; 258 | } 259 | 260 | ctx.textAlign = normalizedAlign; 261 | 262 | let textX = x + width / 2; 263 | if (normalizedAlign === "left") { 264 | const paddingLeft = parseFloat(computed.paddingLeft) || 0; 265 | textX = x + paddingLeft; 266 | } else if (normalizedAlign === "right") { 267 | const paddingRight = parseFloat(computed.paddingRight) || 0; 268 | textX = x + width - paddingRight; 269 | } 270 | 271 | ctx.fillText(text, textX, y + titleOffset); 272 | }); 273 | }); 274 | 275 | return { canvas, width: totalWidth, height: totalHeight }; 276 | } 277 | 278 | const downloadPaletteTableAsPng = async () => { 279 | if (!exportState.palettes || !exportState.palettes.length) return; 280 | const tableWrapper = document.getElementById("tints-and-shades"); 281 | if (!tableWrapper) return; 282 | const paletteWrappers = Array.from(tableWrapper.querySelectorAll(".palette-wrapper")); 283 | if (!paletteWrappers.length) return; 284 | const aggregatedTable = document.createElement("table"); 285 | const firstTable = paletteWrappers[0].querySelector("table"); 286 | if (firstTable) aggregatedTable.className = firstTable.className; 287 | 288 | const copyComputedProperties = (sourceElement, targetElement, properties) => { 289 | if (!sourceElement || !targetElement) return; 290 | const computed = window.getComputedStyle(sourceElement); 291 | properties.forEach((property) => { 292 | const value = computed.getPropertyValue(property); 293 | if (value !== "") { 294 | targetElement.style.setProperty(property, value); 295 | } 296 | }); 297 | }; 298 | 299 | const copyRowStyles = (sourceRow, targetRow) => { 300 | Array.from(sourceRow.cells).forEach((cell, index) => { 301 | const targetCell = targetRow.cells[index]; 302 | if (!targetCell) return; 303 | copyComputedProperties(cell, targetCell, [ 304 | "background-color", 305 | "color", 306 | "font-family", 307 | "font-size", 308 | "font-weight", 309 | "line-height", 310 | "letter-spacing", 311 | "text-align", 312 | "direction", 313 | "text-transform", 314 | "padding-left", 315 | "padding-right", 316 | "padding-top", 317 | "padding-bottom", 318 | "box-sizing" 319 | ]); 320 | const cellRect = cell.getBoundingClientRect(); 321 | targetCell.style.setProperty("width", `${Math.max(1, Math.round(cellRect.width))}px`); 322 | targetCell.style.setProperty("height", `${Math.max(1, Math.round(cellRect.height))}px`); 323 | }); 324 | }; 325 | 326 | paletteWrappers.forEach((wrapper, index) => { 327 | const paletteNameLabel = wrapper.querySelector(".palette-titlebar-name"); 328 | const paletteNameText = (paletteNameLabel && paletteNameLabel.textContent) 329 | ? paletteNameLabel.textContent.trim() 330 | : ""; 331 | const innerTable = wrapper.querySelector("table"); 332 | if (!innerTable) return; 333 | const headerRow = innerTable.querySelector(".table-header"); 334 | const columnCount = headerRow 335 | ? headerRow.cells.length 336 | : (innerTable.rows[0] ? innerTable.rows[0].cells.length : 1); 337 | 338 | if (paletteNameText) { 339 | const nameRow = document.createElement("tr"); 340 | nameRow.className = "palette-titlebar"; 341 | const nameCell = document.createElement("td"); 342 | nameCell.setAttribute("colspan", `${columnCount}`); 343 | nameCell.textContent = paletteNameText; 344 | nameCell.style.paddingBottom = "8px"; 345 | if (paletteNameLabel) { 346 | copyComputedProperties(paletteNameLabel, nameCell, [ 347 | "color", 348 | "font-family", 349 | "font-size", 350 | "font-weight", 351 | "letter-spacing", 352 | "text-transform" 353 | ]); 354 | } 355 | nameRow.appendChild(nameCell); 356 | aggregatedTable.appendChild(nameRow); 357 | } 358 | 359 | if (headerRow) { 360 | const clonedHeader = headerRow.cloneNode(true); 361 | copyRowStyles(headerRow, clonedHeader); 362 | Array.from(clonedHeader.cells).forEach((cell) => { 363 | cell.style.paddingBottom = "4px"; 364 | cell.style.paddingTop = "6px"; 365 | cell.style.verticalAlign = "bottom"; 366 | cell.style.lineHeight = "1.2"; 367 | }); 368 | aggregatedTable.appendChild(clonedHeader); 369 | } 370 | 371 | innerTable.querySelectorAll("tbody tr").forEach((row) => { 372 | const clonedRow = row.cloneNode(true); 373 | copyRowStyles(row, clonedRow); 374 | aggregatedTable.appendChild(clonedRow); 375 | }); 376 | 377 | const isLastPalette = index === paletteWrappers.length - 1; 378 | if (!isLastPalette) { 379 | const spacerRow = document.createElement("tr"); 380 | const spacerCell = document.createElement("td"); 381 | spacerCell.setAttribute("colspan", `${columnCount}`); 382 | spacerCell.innerHTML = " "; 383 | spacerCell.style.height = "8px"; 384 | spacerCell.style.lineHeight = "8px"; 385 | spacerCell.style.border = "none"; 386 | spacerRow.appendChild(spacerCell); 387 | aggregatedTable.appendChild(spacerRow); 388 | } 389 | }); 390 | 391 | const rowCount = aggregatedTable.rows.length; 392 | if (!rowCount) return; 393 | 394 | const hiddenWrapper = document.createElement("div"); 395 | Object.assign(hiddenWrapper.style, { 396 | position: "absolute", 397 | top: "-9999px", 398 | left: "-9999px", 399 | opacity: "0", 400 | pointerEvents: "none", 401 | }); 402 | const paletteTableShim = document.createElement("div"); 403 | paletteTableShim.className = "palette-table"; 404 | paletteTableShim.appendChild(aggregatedTable); 405 | hiddenWrapper.appendChild(paletteTableShim); 406 | tableWrapper.appendChild(hiddenWrapper); 407 | 408 | let canvasResult = null; 409 | try { 410 | canvasResult = createTableCanvas(aggregatedTable); 411 | } finally { 412 | if (hiddenWrapper.parentNode) { 413 | hiddenWrapper.parentNode.removeChild(hiddenWrapper); 414 | } 415 | } 416 | if (!canvasResult || !canvasResult.canvas) return; 417 | const { canvas } = canvasResult; 418 | const imageButton = exportElements.imageButton; 419 | if (imageButton) { 420 | imageButton.disabled = true; 421 | imageButton.setAttribute("aria-busy", "true"); 422 | } 423 | try { 424 | const blob = await canvasToBlob(canvas); 425 | if (!blob) throw new Error("Failed to create PNG"); 426 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 427 | downloadBlob(blob, `palettes-${timestamp}.png`); 428 | } catch (error) { 429 | console.error("Failed to export palette table as PNG", error); 430 | } finally { 431 | if (imageButton) { 432 | imageButton.disabled = false; 433 | imageButton.removeAttribute("aria-busy"); 434 | } 435 | } 436 | }; 437 | 438 | const lockBodyScroll = () => { 439 | if (document.body.classList.contains("modal-open")) return; 440 | pageScrollY = window.scrollY || document.documentElement.scrollTop || 0; 441 | document.body.style.position = "fixed"; 442 | document.body.style.top = `-${pageScrollY}px`; 443 | document.body.style.left = "0"; 444 | document.body.style.right = "0"; 445 | document.body.style.width = "100%"; 446 | document.body.classList.add("modal-open"); 447 | }; 448 | 449 | const unlockBodyScroll = () => { 450 | const scrollToY = pageScrollY || 0; 451 | document.body.classList.remove("modal-open"); 452 | document.body.style.position = ""; 453 | document.body.style.top = ""; 454 | document.body.style.left = ""; 455 | document.body.style.right = ""; 456 | document.body.style.width = ""; 457 | window.scrollTo(0, scrollToY); 458 | }; 459 | 460 | const getDialogFocusable = () => { 461 | if (!exportElements.modal) return []; 462 | const selectors = [ 463 | "button", 464 | "[href]", 465 | 'input:not([type=\"hidden\"])', 466 | "select", 467 | "textarea", 468 | "[tabindex]:not([tabindex='-1'])" 469 | ]; 470 | const nodes = Array.from(exportElements.modal.querySelectorAll(selectors.join(","))); 471 | return nodes.filter((node) => { 472 | const tabIndex = node.tabIndex; 473 | const isHidden = node.getAttribute("aria-hidden") === "true"; 474 | const isDisabled = node.hasAttribute("disabled"); 475 | return !isHidden && !isDisabled && tabIndex !== -1; 476 | }); 477 | }; 478 | 479 | const resetExportScroll = (elements) => { 480 | if (elements.output) { 481 | elements.output.scrollTop = 0; 482 | elements.output.scrollLeft = 0; 483 | } 484 | if (elements.output && elements.output.parentElement) { 485 | elements.output.parentElement.scrollTop = 0; 486 | elements.output.parentElement.scrollLeft = 0; 487 | } 488 | }; 489 | 490 | const formatHexOutput = (palettes, includeHash, stepLabel) => { 491 | if (!palettes.length) return ""; 492 | const prefix = includeHash ? "#" : ""; 493 | const shadesHeader = stepLabel ? `${stepLabel} shades` : "shades"; 494 | const tintsHeader = stepLabel ? `${stepLabel} tints` : "tints"; 495 | const blocks = palettes.map((palette) => { 496 | const rawLabel = palette.label || palette.id; 497 | const label = exportNaming.formatLabelForDisplay(rawLabel) || rawLabel; 498 | const baseLine = `${label} - ${prefix}${palette.base}`; 499 | const shadeLines = palette.shades.map((item) => `${prefix}${item.hex}`); 500 | const tintLines = palette.tints.map((item) => `${prefix}${item.hex}`); 501 | return [ 502 | baseLine, 503 | "", 504 | shadesHeader, 505 | "-----", 506 | ...shadeLines, 507 | "", 508 | tintsHeader, 509 | "-----", 510 | ...tintLines 511 | ].join("\n"); 512 | }); 513 | return blocks.join("\n\n"); 514 | }; 515 | 516 | const formatCssOutput = (palettes) => { 517 | if (!palettes.length) return ""; 518 | const lines = []; 519 | palettes.forEach((palette) => { 520 | const baseName = palette.id; 521 | const rawLabel = palette.label || baseName; 522 | const label = exportNaming.formatLabelForDisplay(rawLabel) || rawLabel; 523 | lines.push(` /* ${label} */`); 524 | lines.push(` --${baseName}-base: #${palette.base};`); 525 | const shadeLines = palette.shades.map((item) => { 526 | const tier = formatCssTier(item.percent); 527 | return ` --${baseName}-shade-${tier}: #${item.hex};`; 528 | }); 529 | const tintLines = palette.tints.map((item) => { 530 | const tier = formatCssTier(item.percent); 531 | return ` --${baseName}-tint-${tier}: #${item.hex};`; 532 | }); 533 | lines.push(...shadeLines, ...tintLines); 534 | }); 535 | return `:root {\n${lines.join("\n")}\n}`; 536 | }; 537 | 538 | const formatJsonOutput = (palettes) => { 539 | if (!palettes.length) return ""; 540 | const makePalettePayload = (palette) => { 541 | const shades = palette.shades.map((item) => { 542 | const step = formatCssTier(item.percent); 543 | return { 544 | step: Number(step), 545 | name: `${palette.id}-shade-${step}`, 546 | hex: `#${item.hex}` 547 | }; 548 | }); 549 | const tints = palette.tints.map((item) => { 550 | const step = formatCssTier(item.percent); 551 | return { 552 | step: Number(step), 553 | name: `${palette.id}-tint-${step}`, 554 | hex: `#${item.hex}` 555 | }; 556 | }); 557 | return { 558 | base: { 559 | name: palette.id, 560 | hex: `#${palette.base}` 561 | }, 562 | shades, 563 | tints 564 | }; 565 | }; 566 | 567 | if (palettes.length === 1) { 568 | return JSON.stringify(makePalettePayload(palettes[0]), null, 2); 569 | } 570 | 571 | const payload = {}; 572 | palettes.forEach((palette) => { 573 | payload[palette.id] = makePalettePayload(palette); 574 | }); 575 | return JSON.stringify(payload, null, 2); 576 | }; 577 | 578 | const normalizeHex = (hex) => { 579 | if (typeof hex !== "string") return ""; 580 | return hex.replace(/^#/, "").trim().slice(0, 6); 581 | }; 582 | 583 | const formatRgbValue = (hex) => { 584 | const normalized = normalizeHex(hex); 585 | if (normalized.length !== 6) return ""; 586 | const r = parseInt(normalized.slice(0, 2), 16); 587 | const g = parseInt(normalized.slice(2, 4), 16); 588 | const b = parseInt(normalized.slice(4, 6), 16); 589 | if ([r, g, b].some((value) => Number.isNaN(value))) return ""; 590 | return `rgb(${r}, ${g}, ${b})`; 591 | }; 592 | 593 | const formatRgbOutput = (palettes, stepLabel) => { 594 | if (!palettes.length) return ""; 595 | const blocks = palettes.map((palette) => { 596 | const rawLabel = palette.label || palette.id; 597 | const label = exportNaming.formatLabelForDisplay(rawLabel) || rawLabel; 598 | const rgbValue = formatRgbValue(palette.base) || palette.base; 599 | const baseLine = `${label} - ${rgbValue}`; 600 | const shadeLines = palette.shades.map((item) => formatRgbValue(item.hex) || item.hex); 601 | const tintLines = palette.tints.map((item) => formatRgbValue(item.hex) || item.hex); 602 | const shadesHeader = stepLabel ? `${stepLabel} shades` : "shades"; 603 | const tintsHeader = stepLabel ? `${stepLabel} tints` : "tints"; 604 | return [ 605 | baseLine, 606 | "", 607 | shadesHeader, 608 | "-----", 609 | ...shadeLines, 610 | "", 611 | tintsHeader, 612 | "-----", 613 | ...tintLines 614 | ].join("\n"); 615 | }); 616 | return blocks.join("\n\n"); 617 | }; 618 | 619 | const getExportText = (state) => { 620 | let stepLabel = ""; 621 | if (state && typeof state.tintShadeCount === "number" && state.tintShadeCount > 0) { 622 | const percentStep = Math.round(100 / state.tintShadeCount); 623 | stepLabel = `${percentStep}%`; 624 | } 625 | if (state.format === "css") return formatCssOutput(state.palettes); 626 | if (state.format === "json") return formatJsonOutput(state.palettes); 627 | if (state.format === "hex-hash") return formatHexOutput(state.palettes, true, stepLabel); 628 | if (state.format === "rgb") return formatRgbOutput(state.palettes, stepLabel); 629 | return formatHexOutput(state.palettes, false, stepLabel); 630 | }; 631 | 632 | const updateExportOutput = (state, elements) => { 633 | if (!elements.output) return; 634 | const text = getExportText(state); 635 | if (elements.code) { 636 | elements.code.textContent = text; 637 | highlightExportCode(elements.code); 638 | } else { 639 | elements.output.textContent = text; 640 | } 641 | elements.output.setAttribute("aria-labelledby", `export-tab-${state.format}`); 642 | }; 643 | 644 | const updateExportCornerRadius = (state, elements) => { 645 | if (!elements.output) return; 646 | const firstTabFormat = elements.tabs[0] ? elements.tabs[0].dataset.format : null; 647 | const isFirstTabActive = state.format === firstTabFormat; 648 | elements.output.classList.toggle("is-first-tab-active", isFirstTabActive); 649 | }; 650 | 651 | const setExportFormat = (format, state, elements) => { 652 | state.format = VALID_EXPORT_FORMATS.includes(format) ? format : "hex"; 653 | persistExportFormat(state.format); 654 | if (elements.tabs.length) { 655 | elements.tabs.forEach((tab) => { 656 | const isActive = tab.dataset.format === state.format; 657 | tab.classList.toggle("is-active", isActive); 658 | tab.setAttribute("aria-selected", isActive ? "true" : "false"); 659 | tab.setAttribute("tabindex", isActive ? "0" : "-1"); 660 | }); 661 | } 662 | updateExportLanguage(state.format, elements); 663 | updateExportCornerRadius(state, elements); 664 | updateExportOutput(state, elements); 665 | resetExportScroll(elements); 666 | }; 667 | 668 | const toggleExportWrapperVisibility = (visible, elements) => { 669 | if (!elements.wrapper || !elements.openButton) return; 670 | elements.wrapper.hidden = !visible; 671 | elements.openButton.disabled = !visible; 672 | elements.openButton.setAttribute("aria-expanded", "false"); 673 | if (elements.imageButton) { 674 | elements.imageButton.disabled = !visible; 675 | elements.imageButton.setAttribute("aria-disabled", visible ? "false" : "true"); 676 | } 677 | }; 678 | 679 | const copyExportOutput = async (state, elements) => { 680 | const text = getExportText(state); 681 | if (!text) return; 682 | try { 683 | if (navigator.clipboard && navigator.clipboard.writeText) { 684 | await navigator.clipboard.writeText(text); 685 | } else { 686 | const helper = document.createElement("textarea"); 687 | helper.value = text; 688 | helper.setAttribute("readonly", ""); 689 | helper.style.position = "absolute"; 690 | helper.style.left = "-9999px"; 691 | document.body.appendChild(helper); 692 | helper.select(); 693 | document.execCommand("copy"); 694 | document.body.removeChild(helper); 695 | } 696 | const btn = elements.copyFab; 697 | if (btn) { 698 | btn.classList.add("copied"); 699 | btn.disabled = true; 700 | btn.setAttribute("aria-disabled", "true"); 701 | setTimeout(() => { 702 | btn.classList.remove("copied"); 703 | btn.disabled = false; 704 | btn.setAttribute("aria-disabled", "false"); 705 | }, 1500); 706 | } 707 | const status = elements.copyStatus; 708 | if (status) { 709 | status.textContent = "Copied export output to clipboard."; 710 | setTimeout(() => { 711 | status.textContent = ""; 712 | }, 4500); 713 | } 714 | } catch (err) { 715 | console.error(err); 716 | } 717 | }; 718 | 719 | const selectExportOutput = () => { 720 | if (!exportElements.output) return; 721 | const selection = window.getSelection && window.getSelection(); 722 | if (!selection) return; 723 | const range = document.createRange(); 724 | range.selectNodeContents(exportElements.output); 725 | selection.removeAllRanges(); 726 | selection.addRange(range); 727 | }; 728 | 729 | const openExportModal = (state, elements) => { 730 | if (!elements.modal) return; 731 | if (!state.palettes.length) return; 732 | setExportFormat(state.format, state, elements); 733 | lockBodyScroll(); 734 | if (typeof elements.modal.showModal === "function") { 735 | elements.modal.showModal(); 736 | } else { 737 | elements.modal.setAttribute("open", "true"); 738 | } 739 | elements.modal.classList.remove("is-closing"); 740 | elements.modal.removeAttribute("data-closing"); 741 | if (!prefersReducedMotion()) { 742 | elements.modal.classList.add("is-opening"); 743 | elements.modal.addEventListener("animationend", (event) => { 744 | if (event.target === elements.modal && event.animationName === "export-dialog-fade") { 745 | elements.modal.classList.remove("is-opening"); 746 | } 747 | }, { once: true }); 748 | } else { 749 | elements.modal.classList.remove("is-opening"); 750 | } 751 | if (!handleOutsidePointerDown) { 752 | handleOutsidePointerDown = (event) => { 753 | if (!elements.modal || !elements.modal.open) return; 754 | const rect = elements.modal.getBoundingClientRect(); 755 | const isOutside = event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; 756 | if (isOutside) { 757 | closeExportModal(elements); 758 | } 759 | }; 760 | document.addEventListener("pointerdown", handleOutsidePointerDown); 761 | } 762 | if (elements.openButton) { 763 | elements.openButton.setAttribute("aria-expanded", "true"); 764 | } 765 | const activeTab = elements.tabs.find((tab) => tab.classList.contains("is-active")) || elements.tabs[0]; 766 | if (activeTab) activeTab.focus(); 767 | // Reset scroll after layout to avoid stale scroll positions on reopen 768 | requestAnimationFrame(() => resetExportScroll(elements)); 769 | }; 770 | 771 | const closeExportModal = (elements) => { 772 | const modal = elements.modal; 773 | if (!modal) return; 774 | if (modal.getAttribute("data-closing") === "true") return; 775 | 776 | const completeClose = () => { 777 | modal.removeAttribute("data-closing"); 778 | modal.classList.remove("is-closing"); 779 | modal.classList.remove("is-opening"); 780 | if (modal.open && typeof modal.close === "function") { 781 | modal.close(); 782 | } else { 783 | modal.removeAttribute("open"); 784 | } 785 | resetExportScroll(elements); 786 | unlockBodyScroll(); 787 | if (handleOutsidePointerDown) { 788 | document.removeEventListener("pointerdown", handleOutsidePointerDown); 789 | handleOutsidePointerDown = null; 790 | } 791 | if (elements.openButton) { 792 | elements.openButton.setAttribute("aria-expanded", "false"); 793 | } 794 | }; 795 | 796 | if (!modal.open && !modal.hasAttribute("open")) { 797 | completeClose(); 798 | return; 799 | } 800 | 801 | if (prefersReducedMotion()) { 802 | completeClose(); 803 | return; 804 | } 805 | 806 | modal.setAttribute("data-closing", "true"); 807 | modal.classList.remove("is-opening"); 808 | modal.classList.add("is-closing"); 809 | 810 | let closeFallbackTimer = null; 811 | const handleAnimationEnd = (event) => { 812 | if (event.target !== modal || event.animationName !== "export-dialog-fade") return; 813 | clearTimeout(closeFallbackTimer); 814 | modal.removeEventListener("animationend", handleAnimationEnd); 815 | completeClose(); 816 | }; 817 | 818 | closeFallbackTimer = setTimeout(() => { 819 | modal.removeEventListener("animationend", handleAnimationEnd); 820 | completeClose(); 821 | }, 250); 822 | 823 | modal.addEventListener("animationend", handleAnimationEnd); 824 | }; 825 | 826 | const wireExportControls = () => { 827 | exportElements.wrapper = document.getElementById("palette-controls-wrapper"); 828 | exportElements.openButton = document.getElementById("export-open"); 829 | exportElements.modal = document.getElementById("export-dialog"); 830 | exportElements.closeButton = document.getElementById("export-close"); 831 | exportElements.tabs = Array.from(document.querySelectorAll(".export-tab")); 832 | exportElements.output = document.getElementById("export-output"); 833 | exportElements.code = exportElements.output 834 | ? exportElements.output.querySelector(".export-output-code") 835 | : null; 836 | exportElements.copyFab = document.getElementById("export-copy"); 837 | exportElements.copyStatus = document.getElementById("export-copy-status"); 838 | exportElements.imageButton = document.getElementById("export-image"); 839 | 840 | if (exportElements.openButton) { 841 | exportElements.openButton.addEventListener("click", () => openExportModal(exportState, exportElements)); 842 | } 843 | 844 | if (exportElements.closeButton) { 845 | exportElements.closeButton.addEventListener("click", () => closeExportModal(exportElements)); 846 | } 847 | 848 | if (exportElements.modal) { 849 | exportElements.modal.addEventListener("cancel", (event) => { 850 | event.preventDefault(); 851 | closeExportModal(exportElements); 852 | }); 853 | exportElements.modal.addEventListener("click", (event) => { 854 | // Prevent accidental close when dragging inside the dialog; 855 | // outside clicks are handled via document-level pointer listener. 856 | event.stopPropagation(); 857 | }); 858 | exportElements.modal.addEventListener("keydown", (event) => { 859 | if (event.key === "Escape") { 860 | event.preventDefault(); 861 | closeExportModal(exportElements); 862 | return; 863 | } 864 | if (event.key === "Tab") { 865 | const focusables = getDialogFocusable(); 866 | if (!focusables.length) return; 867 | const currentIndex = focusables.indexOf(document.activeElement); 868 | let nextIndex = currentIndex; 869 | if (event.shiftKey) { 870 | nextIndex = currentIndex <= 0 ? focusables.length - 1 : currentIndex - 1; 871 | } else { 872 | nextIndex = currentIndex === focusables.length - 1 ? 0 : currentIndex + 1; 873 | } 874 | focusables[nextIndex].focus(); 875 | event.preventDefault(); 876 | } 877 | }); 878 | 879 | } 880 | 881 | if (exportElements.tabs.length) { 882 | exportElements.tabs.forEach((tab) => { 883 | tab.addEventListener("click", () => { 884 | setExportFormat(tab.dataset.format, exportState, exportElements); 885 | }); 886 | tab.addEventListener("keydown", (event) => { 887 | if (event.key === "Enter" || event.key === " ") { 888 | event.preventDefault(); 889 | tab.click(); 890 | } 891 | if (event.key === "Home") { 892 | event.preventDefault(); 893 | const firstTab = exportElements.tabs[0]; 894 | if (firstTab) { 895 | firstTab.click(); 896 | firstTab.focus(); 897 | } 898 | } 899 | if (event.key === "End") { 900 | event.preventDefault(); 901 | const lastTab = exportElements.tabs[exportElements.tabs.length - 1]; 902 | if (lastTab) { 903 | lastTab.click(); 904 | lastTab.focus(); 905 | } 906 | } 907 | if (event.key === "ArrowRight" || event.key === "ArrowDown") { 908 | event.preventDefault(); 909 | const currentIndex = exportElements.tabs.indexOf(tab); 910 | const nextIndex = (currentIndex + 1) % exportElements.tabs.length; 911 | exportElements.tabs[nextIndex].click(); 912 | exportElements.tabs[nextIndex].focus(); 913 | } 914 | if (event.key === "ArrowLeft" || event.key === "ArrowUp") { 915 | event.preventDefault(); 916 | const currentIndex = exportElements.tabs.indexOf(tab); 917 | const prevIndex = (currentIndex - 1 + exportElements.tabs.length) % exportElements.tabs.length; 918 | exportElements.tabs[prevIndex].click(); 919 | exportElements.tabs[prevIndex].focus(); 920 | } 921 | }); 922 | }); 923 | } 924 | 925 | if (exportElements.copyFab) { 926 | exportElements.copyFab.addEventListener("click", () => copyExportOutput(exportState, exportElements)); 927 | } 928 | 929 | if (exportElements.output) { 930 | exportElements.output.addEventListener("click", (event) => { 931 | if (event.detail === 3) { 932 | event.preventDefault(); 933 | selectExportOutput(); 934 | } 935 | }); 936 | } 937 | 938 | if (exportElements.imageButton) { 939 | exportElements.imageButton.addEventListener("click", () => downloadPaletteTableAsPng()); 940 | } 941 | 942 | toggleExportWrapperVisibility(false, exportElements); 943 | setExportFormat(exportState.format, exportState, exportElements); 944 | }; 945 | 946 | const updateClipboardData = (copyWithHashtag) => { 947 | const colorCells = document.querySelectorAll("#tints-and-shades td[data-clipboard-text]"); 948 | colorCells.forEach(cell => { 949 | const colorCode = cell.getAttribute("data-clipboard-text"); 950 | if (copyWithHashtag) { 951 | cell.setAttribute("data-clipboard-text", `#${colorCode}`); 952 | } else { 953 | cell.setAttribute("data-clipboard-text", colorCode.substr(1)); 954 | } 955 | }); 956 | }; 957 | 958 | window.exportUI = { 959 | state: exportState, 960 | elements: exportElements, 961 | formatListOutput: formatHexOutput, 962 | formatCssOutput, 963 | formatJsonOutput, 964 | getExportText, 965 | updateExportOutput, 966 | setExportFormat, 967 | toggleExportWrapperVisibility, 968 | copyExportOutput, 969 | openExportModal, 970 | closeExportModal, 971 | wireExportControls, 972 | updateClipboardData 973 | }; 974 | })(); 975 | --------------------------------------------------------------------------------