Spaces:
Running
Running
| <div class="palettes" style="width:100%; margin: 10px 0;"> | |
| <style> | |
| .palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; } | |
| .palettes .palette-card { position: relative; display: grid; grid-template-columns: auto 1fr 260px; align-items: stretch; gap: 14px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; } | |
| /* removed circular badge */ | |
| .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 8px; margin: 0; } | |
| .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 8px; border: 1px solid var(--border-color); } | |
| .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 24px; border-right: 1px solid var(--border-color); } | |
| .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 .palette-card__actions { display: flex; align-items: center; justify-content: flex-start; justify-self: start; align-self: stretch; } | |
| /* .palettes .copy-btn { margin: 0; padding: 0 10px; height: 100%; border-radius: 8px; } */ | |
| /* .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; } | |
| .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */ | |
| .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; } | |
| /* Simulation UI */ | |
| .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: nowrap; gap: 16px; align-items: center; margin: 8px 0 14px; } | |
| .palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 0 0 50%; max-width: 50%; } | |
| .palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; } | |
| .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); } | |
| /* Slider styling */ | |
| .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; } | |
| /* WebKit */ | |
| .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%; } | |
| /* Firefox */ | |
| .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%; } | |
| /* Page-wide color vision simulation classes */ | |
| html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) ; } | |
| html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) ; } | |
| html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) ; } | |
| html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) ; } | |
| html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) ; } | |
| @media (max-width: 640px) { | |
| .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__actions { justify-self: start; } | |
| } | |
| </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"> | |
| <label class="palettes__label" for="color-count">Number of colors</label> | |
| <div class="palettes__count"> | |
| <input id="color-count" type="range" min="6" max="10" step="1" value="6" aria-label="Number of colors" /> | |
| <output id="color-count-out" for="color-count">6</output> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="palettes__grid"></div> | |
| <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title"> | |
| <!-- Hidden SVG filters used by the page-wide simulation classes --> | |
| <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;"> | |
| <defs> | |
| <!-- Matrices from common color vision deficiency simulations --> | |
| <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"/> | |
| </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"/> | |
| </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"/> | |
| </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"/> | |
| </filter> | |
| </defs> | |
| </svg> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const loadScript = (id, src, onload, onerror) => { | |
| let s = document.getElementById(id); | |
| if (s) { return onload && onload(); } | |
| s = document.createElement('script'); s.id = id; s.src = src; s.async = true; | |
| if (onload) s.addEventListener('load', onload, { once: true }); | |
| if (onerror) s.addEventListener('error', onerror, { once: true }); | |
| document.head.appendChild(s); | |
| }; | |
| const ensureChroma = (next) => { | |
| if (window.chroma) return next(); | |
| const tryReady = () => { if (window.chroma) next(); else setTimeout(tryReady, 25); }; | |
| const existing = document.getElementById('chroma-cdn') || document.getElementById('chroma-cdn-fallback'); | |
| if (existing) { tryReady(); return; } | |
| loadScript('chroma-cdn', 'https://unpkg.com/chroma-js@2.4.2/dist/chroma.min.js', tryReady, () => { | |
| loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', tryReady); | |
| }); | |
| }; | |
| const cards = [ | |
| { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.', generator: (baseHex, count) => { | |
| const base = chroma(baseHex); | |
| const lc = base.lch(); | |
| const baseH = base.get('hsl.h') || 0; | |
| const L0 = Math.max(40, Math.min(85, lc[0] || 70)); | |
| const C0 = Math.max(45, Math.min(75, lc[1] || 70)); | |
| const MIN_DELTA = 18; // distance minimale en Lab | |
| const seen = new Set(); | |
| const results = []; | |
| const makeSafe = (h, L, C) => { | |
| let c = C; | |
| let col = chroma.lch(L, c, h); | |
| let guard = 0; | |
| while (col.clipped && typeof col.clipped === 'function' && col.clipped() && c > 30 && guard < 8) { | |
| c -= 5; | |
| col = chroma.lch(L, c, h); | |
| guard++; | |
| } | |
| return col; | |
| }; | |
| const isFarEnough = (hex) => results.every(prev => chroma.distance(hex, prev, 'lab') >= MIN_DELTA); | |
| const pushHex = (col) => { | |
| const hex = col.hex(); | |
| if (!seen.has(hex.toLowerCase())) { results.push(hex); seen.add(hex.toLowerCase()); } | |
| }; | |
| // Base en premier | |
| pushHex(base); | |
| const total = Math.max(6, Math.min(10, count || 6)); | |
| const hueStep = 360 / total; | |
| const hueOffsets = [0, 18, -18, 36, -36, 54, -54, 72, -72]; | |
| const lVariants = [L0, Math.max(40, L0 - 6), Math.min(85, L0 + 6)]; | |
| for (let idx = 1; results.length < total && idx < total + 12; idx++) { | |
| let stepHue = (baseH + idx * hueStep) % 360; | |
| let accepted = false; | |
| for (let li = 0; li < lVariants.length && !accepted; li++) { | |
| for (let oi = 0; oi < hueOffsets.length && !accepted; oi++) { | |
| const h = (stepHue + hueOffsets[oi] + 360) % 360; | |
| const col = makeSafe(h, lVariants[li], C0); | |
| const hex = col.hex(); | |
| if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) { | |
| pushHex(col); | |
| accepted = true; | |
| } | |
| } | |
| } | |
| if (!accepted) { | |
| // Réduction de chroma si nécessaire | |
| let cTry = C0 - 10; | |
| let trials = 0; | |
| while (!accepted && cTry >= 30 && trials < 6) { | |
| const col = makeSafe(stepHue, L0, cTry); | |
| const hex = col.hex(); | |
| if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) { | |
| pushHex(col); | |
| accepted = true; | |
| break; | |
| } | |
| cTry -= 5; | |
| trials++; | |
| } | |
| if (!accepted) { | |
| let bestHex = null; let bestMin = -1; | |
| hueOffsets.forEach(off => { | |
| const hh = (stepHue + off + 360) % 360; | |
| const cand = makeSafe(hh, L0, C0).hex(); | |
| const minD = results.reduce((m, prev) => Math.min(m, chroma.distance(cand, prev, 'lab')), Infinity); | |
| if (minD > bestMin && !seen.has(cand.toLowerCase())) { bestMin = minD; bestHex = cand; } | |
| }); | |
| if (bestHex) { seen.add(bestHex.toLowerCase()); results.push(bestHex); } | |
| } | |
| } | |
| } | |
| return results.slice(0, total); | |
| }}, | |
| { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.', generator: (baseHex, count) => { | |
| const total = Math.max(6, Math.min(10, count || 6)); | |
| const c = chroma(baseHex).saturate(0.3); | |
| return chroma.scale([c.darken(2), c, c.brighten(2)]).mode('lab').correctLightness(true).colors(total); | |
| }}, | |
| { key: 'diverging', title: 'Diverging', desc: 'For <strong>centered ranges</strong> with <strong>two extremes</strong> around a <strong>baseline</strong>. (e.g., negatives/positives)', generator: (baseHex, count) => { | |
| const total = Math.max(6, Math.min(10, count || 6)); | |
| const h = chroma(baseHex).get('hsl.h'); | |
| const baseH = Number.isFinite(h) ? h : 0; | |
| const compH = (baseH + 180) % 360; | |
| const left = chroma.hsl(baseH, 0.75, 0.55); | |
| const right = chroma.hsl(compH, 0.75, 0.55); | |
| const mid = chroma.mix(left, right, 0.5, 'lch'); | |
| return chroma.scale([left, mid, right]).mode('lch').correctLightness(true).colors(total); | |
| }} | |
| ]; | |
| const getCssPrimary = () => { | |
| try { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); } catch { return ''; } | |
| }; | |
| const getDesiredCount = () => { | |
| const input = document.getElementById('color-count'); | |
| let v = input ? parseInt(input.value, 10) : 6; | |
| if (Number.isNaN(v)) v = 6; | |
| return Math.max(6, Math.min(10, v)); | |
| }; | |
| const render = () => { | |
| const mount = document.currentScript ? document.currentScript.previousElementSibling : null; | |
| const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes'); | |
| if (!root) return; | |
| const grid = root.querySelector('.palettes__grid'); | |
| if (!grid) return; | |
| grid.innerHTML = ''; | |
| const css = getCssPrimary(); | |
| const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB'; | |
| const count = getDesiredCount(); | |
| const html = cards.map((c) => { | |
| const colors = c.generator(baseHex, count).slice(0, count); | |
| const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join(''); | |
| return ` | |
| <div class="palette-card" data-colors="${colors.join(',')}"> | |
| <div class="palette-card__content"> | |
| <div class="palette-card__content__info"> | |
| <div class="palette-card__title">${c.title}</div> | |
| <div class="palette-card__desc">${c.desc}</div> | |
| </div> | |
| <button class="copy-btn 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> | |
| <div class="palette-card__actions"></div> | |
| <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div> | |
| </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 sync = () => { if (out) out.textContent = String(getDesiredCount()); render(); }; | |
| input.addEventListener('input', sync); | |
| try { if (out) out.textContent = String(getDesiredCount()); } catch {} | |
| }; | |
| let copyDelegationSetup = false; | |
| const setupCopyDelegation = () => { | |
| if (copyDelegationSetup) return; | |
| const root = document.querySelector('.palettes'); | |
| if (!root) return; | |
| const grid = root.querySelector('.palettes__grid'); | |
| if (!grid) return; | |
| grid.addEventListener('click', async (e) => { | |
| const target = e.target.closest ? e.target.closest('.copy-btn') : null; | |
| if (!target) return; | |
| const card = target.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 = target.innerHTML; | |
| target.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(() => target.innerHTML = old, 900); | |
| } catch { | |
| window.prompt('Copy palette', json); | |
| } | |
| }); | |
| copyDelegationSetup = true; | |
| }; | |
| const bootstrap = () => { | |
| setupCbSim(); | |
| setupCountControl(); | |
| render(); | |
| setupCopyDelegation(); | |
| const mo = new MutationObserver(() => render()); | |
| mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); | |
| }; | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureChroma(bootstrap), { once: true }); | |
| else ensureChroma(bootstrap); | |
| })(); | |
| </script> | |