| | --- |
| | const rootId = `palettes-${Math.random().toString(36).slice(2)}`; |
| | --- |
| |
|
| | <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;"> |
| | <style is:global> |
| | .palettes { |
| | box-sizing: border-box; |
| | } |
| | .palettes .palettes__grid { |
| | display: grid; |
| | grid-template-columns: 1fr; |
| | gap: 12px; |
| | max-width: 100%; |
| | } |
| | .palettes .palette-card { |
| | position: relative; |
| | display: grid; |
| | grid-template-columns: 1fr minmax(0, 220px); |
| | align-items: stretch; |
| | gap: 12px; |
| | border: 1px solid var(--border-color); |
| | border-radius: 10px; |
| | background: var(--surface-bg); |
| | padding: 12px; |
| | transition: |
| | box-shadow 0.18s ease, |
| | transform 0.18s ease, |
| | border-color 0.18s ease; |
| | min-height: 60px; |
| | } |
| | .palettes .palette-card__preview { |
| | width: 48px; |
| | height: 48px; |
| | border-radius: 999px; |
| | flex: 0 0 auto; |
| | background-size: cover; |
| | background-position: center; |
| | } |
| | .palettes .palette-card__copy { |
| | position: absolute; |
| | top: 50%; |
| | left: 100%; |
| | transform: translateY(-50%); |
| | z-index: 3; |
| | border-left: none; |
| | border-top-left-radius: 0; |
| | border-bottom-left-radius: 0; |
| | } |
| | .palettes .palette-card__copy svg { |
| | width: 18px; |
| | height: 18px; |
| | fill: currentColor; |
| | display: block; |
| | color: inherit; |
| | } |
| | .palettes .palette-card__swatches { |
| | display: grid; |
| | grid-template-columns: repeat(6, minmax(0, 1fr)); |
| | grid-auto-rows: 1fr; |
| | gap: 2px; |
| | margin: 0; |
| | min-height: 60px; |
| | } |
| | .palettes .palette-card__swatches .sw { |
| | width: 100%; |
| | min-width: 0; |
| | height: auto; |
| | border-radius: 0; |
| | border: 1px solid var(--border-color); |
| | } |
| | .palettes .palette-card__swatches .sw:first-child { |
| | border-top-left-radius: 8px; |
| | border-bottom-left-radius: 8px; |
| | } |
| | .palettes .palette-card__swatches .sw:last-child { |
| | border-top-right-radius: 8px; |
| | border-bottom-right-radius: 8px; |
| | } |
| | .palettes .palette-card__content { |
| | display: flex; |
| | flex-direction: row; |
| | align-items: center; |
| | justify-content: flex-start; |
| | gap: 12px; |
| | min-width: 0; |
| | padding-right: 12px; |
| | } |
| | .palettes .palette-card__preview { |
| | width: 48px; |
| | height: 48px; |
| | border-radius: 999px; |
| | position: relative; |
| | flex: 0 0 auto; |
| | overflow: hidden; |
| | } |
| | .palettes .palette-card__preview .dot { |
| | position: absolute; |
| | width: 4px; |
| | height: 4px; |
| | background: #fff; |
| | border-radius: 999px; |
| | box-shadow: 0 0 6px rgba(0, 0, 0, 1); |
| | } |
| | .palettes .palette-card__preview .donut-hole { |
| | position: absolute; |
| | left: 50%; |
| | top: 50%; |
| | transform: translate(-50%, -50%); |
| | width: 24px; |
| | height: 24px; |
| | border-radius: 999px; |
| | background: var(--surface-bg); |
| | box-shadow: 0 0 0 1px var(--border-color) inset; |
| | } |
| | |
| | .palettes .palette-card__content__info { |
| | display: flex; |
| | flex-direction: column; |
| | } |
| | .palettes .palette-card__title { |
| | text-align: left; |
| | font-weight: 800; |
| | font-size: 15px; |
| | } |
| | .palettes .palette-card__desc { |
| | text-align: left; |
| | color: var(--muted-color); |
| | line-height: 1.5; |
| | font-size: 12px; |
| | } |
| | |
| | .palettes .palettes__select { |
| | width: 100%; |
| | max-width: 100%; |
| | border: 1px solid var(--border-color); |
| | background: var(--surface-bg); |
| | color: var(--text-color); |
| | padding: 8px 10px; |
| | border-radius: 8px; |
| | } |
| | .palettes .sr-only { |
| | position: absolute; |
| | width: 1px; |
| | height: 1px; |
| | padding: 0; |
| | margin: -1px; |
| | overflow: hidden; |
| | clip: rect(0, 0, 1px, 1px); |
| | white-space: nowrap; |
| | border: 0; |
| | } |
| | .palettes .palettes__controls { |
| | display: flex; |
| | flex-wrap: wrap; |
| | gap: 16px; |
| | align-items: center; |
| | margin: 8px 0 14px; |
| | } |
| | .palettes .palettes__field { |
| | display: flex; |
| | flex-direction: column; |
| | gap: 6px; |
| | min-width: 0; |
| | flex: 1 1 280px; |
| | max-width: 100%; |
| | } |
| | .palettes .palettes__label { |
| | font-size: 12px; |
| | color: var(--muted-color); |
| | font-weight: 800; |
| | } |
| | .palettes .palettes__label-row { |
| | display: flex; |
| | align-items: center; |
| | justify-content: space-between; |
| | gap: 10px; |
| | } |
| | .palettes .ghost-badge { |
| | font-size: 11px; |
| | padding: 1px 6px; |
| | border-radius: 999px; |
| | border: 1px solid var(--border-color); |
| | color: var(--muted-color); |
| | background: transparent; |
| | font-variant-numeric: tabular-nums; |
| | } |
| | .palettes .palettes__count { |
| | display: flex; |
| | align-items: center; |
| | gap: 8px; |
| | max-width: 100%; |
| | } |
| | .palettes .palettes__count input[type="range"] { |
| | width: 100%; |
| | } |
| | .palettes .palettes__count output { |
| | min-width: 28px; |
| | text-align: center; |
| | font-variant-numeric: tabular-nums; |
| | font-size: 12px; |
| | color: var(--muted-color); |
| | } |
| | .palettes input[type="range"] { |
| | -webkit-appearance: none; |
| | appearance: none; |
| | height: 24px; |
| | background: transparent; |
| | cursor: pointer; |
| | accent-color: var(--primary-color); |
| | } |
| | .palettes input[type="range"]:focus { |
| | outline: none; |
| | } |
| | .palettes input[type="range"]::-webkit-slider-runnable-track { |
| | height: 6px; |
| | background: var(--border-color); |
| | border-radius: 999px; |
| | } |
| | .palettes input[type="range"]::-webkit-slider-thumb { |
| | -webkit-appearance: none; |
| | appearance: none; |
| | margin-top: -6px; |
| | width: 18px; |
| | height: 18px; |
| | background: var(--primary-color); |
| | border: 2px solid var(--surface-bg); |
| | border-radius: 50%; |
| | } |
| | .palettes input[type="range"]::-moz-range-track { |
| | height: 6px; |
| | background: var(--border-color); |
| | border: none; |
| | border-radius: 999px; |
| | } |
| | .palettes input[type="range"]::-moz-range-progress { |
| | height: 6px; |
| | background: var(--primary-color); |
| | border-radius: 999px; |
| | } |
| | .palettes input[type="range"]::-moz-range-thumb { |
| | width: 18px; |
| | height: 18px; |
| | background: var(--primary-color); |
| | border: 2px solid var(--surface-bg); |
| | border-radius: 50%; |
| | } |
| | html.cb-grayscale, |
| | body.cb-grayscale { |
| | filter: grayscale(1) !important; |
| | } |
| | html.cb-protanopia, |
| | body.cb-protanopia { |
| | filter: url(#cb-protanopia) !important; |
| | } |
| | html.cb-deuteranopia, |
| | body.cb-deuteranopia { |
| | filter: url(#cb-deuteranopia) !important; |
| | } |
| | html.cb-tritanopia, |
| | body.cb-tritanopia { |
| | filter: url(#cb-tritanopia) !important; |
| | } |
| | html.cb-achromatopsia, |
| | body.cb-achromatopsia { |
| | filter: url(#cb-achromatopsia) !important; |
| | } |
| | @media (max-width: 1100px) { |
| | .palettes .palette-card { |
| | grid-template-columns: 1fr; |
| | align-items: stretch; |
| | gap: 10px; |
| | } |
| | .palettes .palette-card__swatches { |
| | grid-template-columns: repeat(6, minmax(0, 1fr)); |
| | } |
| | .palettes .palette-card__content { |
| | border-right: none; |
| | padding-right: 0; |
| | } |
| | .palettes .palette-card__copy { |
| | display: none; |
| | } |
| | } |
| | </style> |
| | <div class="palettes__controls"> |
| | <div class="palettes__field"> |
| | <label class="palettes__label" for="cb-select" |
| | >Color vision simulation</label |
| | > |
| | <select id="cb-select" class="palettes__select"> |
| | <option value="none" |
| | >Normal color vision — typical for most people</option |
| | > |
| | <option value="achromatopsia">Achromatopsia — no color at all</option> |
| | <option value="protanopia">Protanopia — reduced/absent reds</option> |
| | <option value="deuteranopia" |
| | >Deuteranopia — reduced/absent greens</option |
| | > |
| | <option value="tritanopia">Tritanopia — reduced/absent blues</option> |
| | </select> |
| | </div> |
| | <div class="palettes__field"> |
| | <div class="palettes__label-row"> |
| | <label class="palettes__label" for="color-count">Number of colors</label |
| | > |
| | <output id="color-count-out" for="color-count" class="ghost-badge" |
| | >8</output |
| | > |
| | </div> |
| | <div class="palettes__count"> |
| | <input |
| | id="color-count" |
| | type="range" |
| | min="6" |
| | max="10" |
| | step="1" |
| | value="8" |
| | aria-label="Number of colors" |
| | /> |
| | </div> |
| | </div> |
| | </div> |
| | <div class="palettes__grid"></div> |
| | <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title"> |
| | <svg |
| | aria-hidden="true" |
| | focusable="false" |
| | width="0" |
| | height="0" |
| | style="position:absolute; left:-9999px; overflow:hidden;" |
| | > |
| | <defs> |
| | <filter id="cb-protanopia" |
| | ><feColorMatrix |
| | type="matrix" |
| | values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0" |
| | ></feColorMatrix></filter |
| | > |
| | <filter id="cb-deuteranopia" |
| | ><feColorMatrix |
| | type="matrix" |
| | values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0" |
| | ></feColorMatrix></filter |
| | > |
| | <filter id="cb-tritanopia" |
| | ><feColorMatrix |
| | type="matrix" |
| | values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0" |
| | ></feColorMatrix></filter |
| | > |
| | <filter id="cb-achromatopsia" |
| | ><feColorMatrix |
| | type="matrix" |
| | values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0" |
| | ></feColorMatrix></filter |
| | > |
| | </defs> |
| | </svg> |
| | </div> |
| | </div> |
| | <script type="module" is:inline> |
| | import "/scripts/color-palettes.js"; |
| | const ROOT_ID = "{rootId}"; |
| | (() => { |
| | const cards = [ |
| | { |
| | key: "categorical", |
| | title: "Categorical", |
| | desc: "For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.", |
| | }, |
| | { |
| | key: "sequential", |
| | title: "Sequential", |
| | desc: "For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.", |
| | }, |
| | { |
| | key: "diverging", |
| | title: "Diverging", |
| | desc: "For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.", |
| | }, |
| | ]; |
| | const getPaletteColors = (key, count) => { |
| | const total = Number(count) || 6; |
| | if ( |
| | window.ColorPalettes && |
| | typeof window.ColorPalettes.getColors === "function" |
| | ) { |
| | return window.ColorPalettes.getColors(key, total) || []; |
| | } |
| | return []; |
| | }; |
| | const render = () => { |
| | const root = |
| | document.getElementById(ROOT_ID) || document.querySelector(".palettes"); |
| | if (!root) return; |
| | const grid = root.querySelector(".palettes__grid"); |
| | if (!grid) return; |
| | const input = document.getElementById("color-count"); |
| | const total = input ? Number(input.value) || 6 : 6; |
| | const html = cards |
| | .map((c) => { |
| | const colors = getPaletteColors(c.key, total); |
| | const swatches = colors |
| | .map( |
| | (col) => `<div class=\"sw\" style=\"background:${col}\"></div>`, |
| | ) |
| | .join(""); |
| | const baseHex = |
| | window.ColorPalettes && |
| | typeof window.ColorPalettes.getPrimary === "function" |
| | ? window.ColorPalettes.getPrimary() |
| | : colors[0] || "#FF0000"; |
| | const hueDeg = (() => { |
| | try { |
| | const s = baseHex.replace("#", ""); |
| | const v = |
| | s.length === 3 |
| | ? s |
| | .split("") |
| | .map((ch) => ch + ch) |
| | .join("") |
| | : s; |
| | const r = parseInt(v.slice(0, 2), 16) / 255, |
| | g = parseInt(v.slice(2, 4), 16) / 255, |
| | b = parseInt(v.slice(4, 6), 16) / 255; |
| | const M = Math.max(r, g, b), |
| | m = Math.min(r, g, b), |
| | d = M - m; |
| | if (d === 0) return 0; |
| | let h = 0; |
| | if (M === r) h = ((g - b) / d) % 6; |
| | else if (M === g) h = (b - r) / d + 2; |
| | else h = (r - g) / d + 4; |
| | h *= 60; |
| | if (h < 0) h += 360; |
| | return h; |
| | } catch { |
| | return 0; |
| | } |
| | })(); |
| | const gradient = |
| | c.key === "categorical" |
| | ? (() => { |
| | const steps = 60; |
| | const wheel = Array.from( |
| | { length: steps }, |
| | (_, i) => |
| | `hsl(${Math.round((i / steps) * 360)}, 100%, 50%)`, |
| | ).join(", "); |
| | return `conic-gradient(${wheel})`; |
| | })() |
| | : colors.length |
| | ? `linear-gradient(90deg, ${colors.join(", ")})` |
| | : `linear-gradient(90deg, var(--border-color), var(--border-color))`; |
| | const previewInner = (() => { |
| | if (c.key !== "categorical" || !colors.length) return ""; |
| | const ring = 18; |
| | const cx = 24; |
| | const cy = 24; |
| | const offset = (hueDeg / 360) * 2 * Math.PI; |
| | return colors |
| | .map((col, i) => { |
| | const angle = offset + (i / colors.length) * 2 * Math.PI; |
| | const x = cx + ring * Math.cos(angle); |
| | const y = cy + ring * Math.sin(angle); |
| | return `<span class=\"dot\" style=\"left:${x - 2}px; top:${y - 2}px\"></span>`; |
| | }) |
| | .join(""); |
| | })(); |
| | const donutHole = |
| | c.key === "categorical" ? '<span class="donut-hole"></span>' : ""; |
| | return ` |
| | <div class="palette-card" data-colors="${colors.join(",")}"> |
| | <div class="palette-card__content"> |
| | <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div> |
| | <div class="palette-card__content__info"> |
| | <div class="palette-card__title">${c.title}</div> |
| | <div class="palette-card__desc">${c.desc}</div> |
| | </div> |
| | </div> |
| | <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div> |
| | <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette"> |
| | <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg> |
| | </button> |
| | </div>`; |
| | }) |
| | .join(""); |
| | grid.innerHTML = html; |
| | }; |
| | const MODE_TO_CLASS = { |
| | protanopia: "cb-protanopia", |
| | deuteranopia: "cb-deuteranopia", |
| | tritanopia: "cb-tritanopia", |
| | achromatopsia: "cb-achromatopsia", |
| | }; |
| | const CLEAR_CLASSES = Object.values(MODE_TO_CLASS); |
| | const clearCbClasses = () => { |
| | const rootEl = document.documentElement; |
| | CLEAR_CLASSES.forEach((cls) => rootEl.classList.remove(cls)); |
| | }; |
| | const applyCbClass = (mode) => { |
| | clearCbClasses(); |
| | const cls = MODE_TO_CLASS[mode]; |
| | if (cls) document.documentElement.classList.add(cls); |
| | }; |
| | const currentCbMode = () => { |
| | const rootEl = document.documentElement; |
| | for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { |
| | if (rootEl.classList.contains(cls)) return mode; |
| | } |
| | return "none"; |
| | }; |
| | const setupCbSim = () => { |
| | const select = document.getElementById("cb-select"); |
| | if (!select) return; |
| | try { |
| | select.value = currentCbMode(); |
| | } catch {} |
| | select.addEventListener("change", () => applyCbClass(select.value)); |
| | }; |
| | const setupCountControl = () => { |
| | const input = document.getElementById("color-count"); |
| | const out = document.getElementById("color-count-out"); |
| | if (!input) return; |
| | const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); |
| | const read = () => clamp(Number(input.value) || 6, 6, 10); |
| | const syncOut = () => { |
| | if (out) out.textContent = String(read()); |
| | }; |
| | const onChange = () => { |
| | syncOut(); |
| | render(); |
| | }; |
| | syncOut(); |
| | input.addEventListener("input", onChange); |
| | document.addEventListener("palettes:updated", () => { |
| | syncOut(); |
| | render(); |
| | }); |
| | }; |
| | let copyDelegationSetup = false; |
| | const setupCopyDelegation = () => { |
| | if (copyDelegationSetup) return; |
| | const grid = document.querySelector(".palettes .palettes__grid"); |
| | if (!grid) return; |
| | grid.addEventListener("click", async (e) => { |
| | const btn = e.target.closest |
| | ? e.target.closest(".palette-card__copy") |
| | : null; |
| | if (!btn) return; |
| | const card = btn.closest(".palette-card"); |
| | if (!card) return; |
| | const colors = (card.dataset.colors || "").split(",").filter(Boolean); |
| | const json = JSON.stringify(colors, null, 2); |
| | try { |
| | await navigator.clipboard.writeText(json); |
| | const old = btn.innerHTML; |
| | btn.innerHTML = |
| | '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; |
| | setTimeout(() => (btn.innerHTML = old), 900); |
| | } catch { |
| | window.prompt("Copy palette", json); |
| | } |
| | }); |
| | copyDelegationSetup = true; |
| | }; |
| | const bootstrap = () => { |
| | setupCbSim(); |
| | setupCountControl(); |
| | setupCopyDelegation(); |
| | |
| | render(); |
| | |
| | document.addEventListener("palettes:updated", render); |
| | |
| | try { |
| | if ( |
| | window.ColorPalettes && |
| | typeof window.ColorPalettes.notify === "function" |
| | ) |
| | window.ColorPalettes.notify(); |
| | else if ( |
| | window.ColorPalettes && |
| | typeof window.ColorPalettes.refresh === "function" |
| | ) |
| | window.ColorPalettes.refresh(); |
| | } catch {} |
| | }; |
| | if (document.readyState === "loading") { |
| | document.addEventListener("DOMContentLoaded", bootstrap, { once: true }); |
| | } else { |
| | bootstrap(); |
| | } |
| | })(); |
| | </script> |
| |
|