diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/theme-studio/colormath.js | 52 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 23 | ||||
| -rw-r--r-- | scripts/theme-studio/test-colormath.mjs | 82 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 179 |
4 files changed, 317 insertions, 19 deletions
diff --git a/scripts/theme-studio/colormath.js b/scripts/theme-studio/colormath.js index 9edcfc02..367b6abe 100644 --- a/scripts/theme-studio/colormath.js +++ b/scripts/theme-studio/colormath.js @@ -117,4 +117,54 @@ function deltaE(aHex, bHex) { return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b); } -export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE }; +// --- WCAG 2.x relative luminance + contrast (migrated from the page inline) --- +// rl reuses the canonical lin() above. On 8-bit channels lin's 0.04045 cutoff is +// byte-identical to the WCAG 0.03928 piecewise the inline copy used — no channel +// value falls between the two thresholds (10/255 = 0.0392, 11/255 = 0.0431) — so +// every #rrggbb contrast value is preserved exactly. +function rl(hex) { + const [R, G, B] = hex2rgb(hex); + return 0.2126 * lin(R / 255) + 0.7152 * lin(G / 255) + 0.0722 * lin(B / 255); +} + +function contrast(aHex, bHex) { + const L1 = rl(aHex), L2 = rl(bHex), hi = Math.max(L1, L2), lo = Math.min(L1, L2); + return (hi + 0.05) / (lo + 0.05); +} + +function rating(r) { return r >= 7 ? 'AAA' : r >= 4.5 ? 'AA' : 'FAIL'; } + +// --- HSV <-> sRGB for the color picker (migrated from the page inline) --- +function hsv2rgb(h, s, v) { + h = (h % 360 + 360) % 360 / 360; + const i = Math.floor(h * 6), f = h * 6 - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + let r, g, b; + switch (((i % 6) + 6) % 6) { + case 0: [r, g, b] = [v, t, p]; break; + case 1: [r, g, b] = [q, v, p]; break; + case 2: [r, g, b] = [p, v, t]; break; + case 3: [r, g, b] = [p, q, v]; break; + case 4: [r, g, b] = [t, p, v]; break; + default: [r, g, b] = [v, p, q]; + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function rgb2hsv(r, g, b) { + r /= 255; g /= 255; b /= 255; + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; + let h = 0; + if (d) { + if (mx === r) h = ((g - b) / d + 6) % 6; + else if (mx === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + } + return [h, mx ? d / mx : 0, mx]; +} + +function rgb2hex(r, g, b) { + return '#' + [r, g, b].map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join(''); +} + +export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex }; diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 8c0e2721..c73a78f5 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -1,5 +1,13 @@ import json, os HERE=os.path.dirname(os.path.abspath(__file__)) +# Pure color-math core, inlined verbatim into the page so the browser runs the +# same code the Node tests import (one source of truth). Strip the ES-module +# `export` line(s) — a top-level export is a syntax error in a classic <script>. +# test-colormath.mjs applies the identical strip and asserts the page carries this +# body verbatim (inline-integrity), so the two copies cannot drift. +COLORMATH_BODY='\n'.join( + l for l in open(os.path.join(HERE,'colormath.js')).read().splitlines() + if not l.startswith('export')).rstrip() ns={} src=open(os.path.join(HERE,'samples.py')).read() exec(src[:src.index('cols=')], ns) @@ -513,11 +521,11 @@ function packagesForExport(map){const out={};for(const app in map){const faces={ function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} -function lin(c){c/=255;return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);} -function rl(h){return 0.2126*lin(parseInt(h.substr(1,2),16))+0.7152*lin(parseInt(h.substr(3,2),16))+0.0722*lin(parseInt(h.substr(5,2),16));} +// Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, +// textOn, and ratingColor stay below as UI-boundary helpers. +COLORMATH_J function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} -function contrast(a,b){const L1=rl(a),L2=rl(b),hi=Math.max(L1,L2),lo=Math.min(L1,L2);return (hi+0.05)/(lo+0.05);} -function rating(r){return r>=7?'AAA':r>=4.5?'AA':'FAIL';} function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} function cid(l){return l.replace(/\\W/g,'');} function buildLangSel(){const s=document.getElementById('langsel');s.innerHTML='';for(const lang in SAMPLES){const o=document.createElement('option');o.value=lang;o.textContent=lang;s.appendChild(o);}} @@ -603,10 +611,6 @@ function updateColor(){ } function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} -function hsv2rgb(h,s,v){h=(h%360+360)%360/360;const i=Math.floor(h*6),f=h*6-i,p=v*(1-s),q=v*(1-f*s),t=v*(1-(1-f)*s);let r,g,b;switch(((i%6)+6)%6){case 0:[r,g,b]=[v,t,p];break;case 1:[r,g,b]=[q,v,p];break;case 2:[r,g,b]=[p,v,t];break;case 3:[r,g,b]=[p,q,v];break;case 4:[r,g,b]=[t,p,v];break;default:[r,g,b]=[v,p,q];}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)];} -function rgb2hsv(r,g,b){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;let h=0;if(d){if(mx===r)h=((g-b)/d+6)%6;else if(mx===g)h=(b-r)/d+2;else h=(r-g)/d+4;h*=60;}return[h,mx?d/mx:0,mx];} -function hex2rgb(h){return[parseInt(h.substr(1,2),16),parseInt(h.substr(3,2),16),parseInt(h.substr(5,2),16)];} -function rgb2hex(r,g,b){return '#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; function pkThresh(){return pkMode==='aa'?4.5:pkMode==='aaa'?7:0;} @@ -1111,7 +1115,8 @@ if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5 if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);} if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}} </script>""" -HTML=(HTML.replace("SAMPLES_J",json.dumps(SAMPLES)) +HTML=(HTML.replace("COLORMATH_J",COLORMATH_BODY) + .replace("SAMPLES_J",json.dumps(SAMPLES)) .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS)) .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS)) .replace("BOLD_J",json.dumps(BOLD)).replace("MAP_J",json.dumps(MAP))) diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs index 6ef0ed5f..2e929b7b 100644 --- a/scripts/theme-studio/test-colormath.mjs +++ b/scripts/theme-studio/test-colormath.mjs @@ -7,9 +7,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE } from './colormath.js'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE, + hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, +} from './colormath.js'; const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps; +const here = fileURLToPath(new URL('.', import.meta.url)); +// Same export-strip generate.py applies before inlining (drop `export` lines, rstrip). +const stripExports = (s) => + s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, ''); test('srgb2oklab achromatic anchors', () => { const w = srgb2oklab('#ffffff'); @@ -67,3 +76,74 @@ test('gamut clamp preserves L/H, reduces C, flags clamped', () => { const ing = oklch2hex(0.591, 0.052, 251.6); // in gamut assert.equal(ing.clamped, false); }); + +test('hex2rgb parses channels', () => { + assert.deepEqual(hex2rgb('#000000'), [0, 0, 0]); + assert.deepEqual(hex2rgb('#ffffff'), [255, 255, 255]); + assert.deepEqual(hex2rgb('#67809c'), [0x67, 0x80, 0x9c]); +}); + +test('WCAG relative luminance anchors', () => { + assert.ok(close(rl('#ffffff'), 1.0, 1e-9), `white ${rl('#ffffff')}`); + assert.ok(close(rl('#000000'), 0.0, 1e-9), `black ${rl('#000000')}`); + assert.ok(rl('#ff0000') < rl('#00ff00'), 'green brighter than red'); // 0.2126 vs 0.7152 +}); + +test('WCAG contrast: symmetry, identity, and known extremes', () => { + assert.ok(close(contrast('#000000', '#ffffff'), 21, 1e-6), 'black/white = 21:1'); + assert.equal(contrast('#67809c', '#67809c'), 1); // identical colors + assert.ok(close(contrast('#0d0b0a', '#67809c'), contrast('#67809c', '#0d0b0a'), 1e-12), + 'order-independent'); + // dupre keyword-blue on ground, a real palette pair (sanity, not a hand-typed number). + assert.ok(contrast('#67809c', '#0d0b0a') > 4.5, 'dupre blue clears AA on ground'); +}); + +test('rating bands at the AA/AAA boundaries', () => { + assert.equal(rating(7.0), 'AAA'); + assert.equal(rating(6.99), 'AA'); + assert.equal(rating(4.5), 'AA'); + assert.equal(rating(4.49), 'FAIL'); + assert.equal(rating(0), 'FAIL'); +}); + +test('hsv2rgb primaries and achromatic edges', () => { + assert.deepEqual(hsv2rgb(0, 1, 1), [255, 0, 0]); + assert.deepEqual(hsv2rgb(120, 1, 1), [0, 255, 0]); + assert.deepEqual(hsv2rgb(240, 1, 1), [0, 0, 255]); + assert.deepEqual(hsv2rgb(0, 0, 1), [255, 255, 255]); // s=0 -> grey (white) + assert.deepEqual(hsv2rgb(0, 0, 0), [0, 0, 0]); // v=0 -> black + assert.deepEqual(hsv2rgb(360, 1, 1), [255, 0, 0]); // hue wraps +}); + +test('rgb2hsv inverts hsv2rgb (saturation/value), hue for chromatic inputs', () => { + assert.deepEqual(rgb2hsv(255, 0, 0), [0, 1, 1]); + assert.deepEqual(rgb2hsv(0, 0, 0), [0, 0, 0]); // black: h and s undefined -> 0 + const [h, s, v] = rgb2hsv(0, 255, 0); + assert.ok(close(h, 120, 1e-9) && s === 1 && v === 1, `green hsv ${h},${s},${v}`); +}); + +test('hsv <-> rgb round-trip property over random colors', () => { + let seed = 1234567; // deterministic LCG: no Math.random, repeatable failures + const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + for (let i = 0; i < 500; i++) { + const rgb = [Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256)]; + const [h, s, v] = rgb2hsv(...rgb); + assert.deepEqual(hsv2rgb(h, s, v), rgb, `round-trip ${rgb}`); + } +}); + +test('rgb2hex formats and clamps out-of-range channels', () => { + assert.equal(rgb2hex(0, 0, 0), '#000000'); + assert.equal(rgb2hex(255, 255, 255), '#ffffff'); + assert.equal(rgb2hex(0x67, 0x80, 0x9c), '#67809c'); + assert.equal(rgb2hex(-5, 300, 128), '#00ff80'); // clamps below 0 and above 255 +}); + +// Guards the one-source-of-truth contract: the page must carry colormath.js's +// body (sans exports) verbatim, so the inlined copy and the tested module cannot +// drift. Requires `python3 generate.py` to have run first. +test('inline-integrity: theme-studio.html contains the colormath.js body verbatim', () => { + const body = stripExports(readFileSync(here + 'colormath.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing the colormath.js body verbatim'); +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index e925b7f4..6703a17f 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -142,11 +142,178 @@ function packagesForExport(map){const out={};for(const app in map){const faces={ function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} -function lin(c){c/=255;return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);} -function rl(h){return 0.2126*lin(parseInt(h.substr(1,2),16))+0.7152*lin(parseInt(h.substr(3,2),16))+0.0722*lin(parseInt(h.substr(5,2),16));} +// Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, +// textOn, and ratingColor stay below as UI-boundary helpers. +// colormath.js — pure color-math core for theme-studio. +// +// One source of truth: node imports this module (tests); generate.py inlines its +// body into the page (stripping the trailing export block) so the browser runs +// the same code. No DOM, no side effects. +// +// Algorithms: OKLab/OKLCH from Bjorn Ottosson (2020, +// https://bottosson.github.io/posts/oklab/); APCA from APCA-W3 0.1.9 +// (https://github.com/Myndex/apca-w3); deltaE is OKLab Euclidean distance. + +function hex2rgb(h) { + return [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)]; +} + +// sRGB transfer (0..1 channel <-> linear-light). +function lin(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } +function delin(c) { return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; } +function clamp01(c) { return c < 0 ? 0 : c > 1 ? 1 : c; } + +function srgb2oklab(hex) { + const [R, G, B] = hex2rgb(hex); + const r = lin(R / 255), g = lin(G / 255), b = lin(B / 255); + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s); + return { + L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, + }; +} + +function oklab2oklch(lab) { + let H = Math.atan2(lab.b, lab.a) * 180 / Math.PI; + if (H < 0) H += 360; + return { L: lab.L, C: Math.hypot(lab.a, lab.b), H }; +} + +function oklch2oklab(L, C, H) { + const hr = H * Math.PI / 180; + return { L, a: C * Math.cos(hr), b: C * Math.sin(hr) }; +} + +// OKLab -> linear sRGB (may fall outside [0,1] when out of gamut). +function oklab2lrgb(L, a, b) { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ]; +} + +function inGamut(lrgb) { + const e = 1e-4; + return lrgb.every(c => c >= -e && c <= 1 + e); +} + +function lrgb2hex(lrgb) { + return '#' + lrgb.map(c => { + const v = Math.round(clamp01(delin(clamp01(c))) * 255); + return v.toString(16).padStart(2, '0'); + }).join(''); +} + +// OKLCH -> in-gamut sRGB hex. When the requested chroma is unreachable, reduce C +// by binary search holding L and H fixed; report whether clamping happened. +function oklch2hex(L, C, H) { + const lab0 = oklch2oklab(L, C, H); + const lrgb0 = oklab2lrgb(lab0.L, lab0.a, lab0.b); + if (inGamut(lrgb0)) return { hex: lrgb2hex(lrgb0), clamped: false }; + let lo = 0, hi = C; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + const lab = oklch2oklab(L, mid, H); + if (inGamut(oklab2lrgb(lab.L, lab.a, lab.b))) lo = mid; else hi = mid; + } + const lab = oklch2oklab(L, lo, H); + return { hex: lrgb2hex(oklab2lrgb(lab.L, lab.a, lab.b)), clamped: true }; +} + +// APCA-W3 0.1.9. Returns signed Lc: positive for dark-text-on-light, negative +// for light-text-on-dark. Constants transcribed verbatim from the pinned source. +function apcaY(hex) { + const [R, G, B] = hex2rgb(hex); + return 0.2126729 * Math.pow(R / 255, 2.4) + + 0.7151522 * Math.pow(G / 255, 2.4) + + 0.0721750 * Math.pow(B / 255, 2.4); +} + +function apca(textHex, bgHex) { + const blkThrs = 0.022, blkClmp = 1.414, deltaYmin = 0.0005; + const normBG = 0.56, normTXT = 0.57, revTXT = 0.62, revBG = 0.65; + const scaleBoW = 1.14, scaleWoB = 1.14, loBoWoffset = 0.027, loWoBoffset = 0.027, loClip = 0.1; + let Ytxt = apcaY(textHex), Ybg = apcaY(bgHex); + Ytxt = Ytxt > blkThrs ? Ytxt : Ytxt + Math.pow(blkThrs - Ytxt, blkClmp); + Ybg = Ybg > blkThrs ? Ybg : Ybg + Math.pow(blkThrs - Ybg, blkClmp); + if (Math.abs(Ybg - Ytxt) < deltaYmin) return 0; + let out; + if (Ybg > Ytxt) { + const sapc = (Math.pow(Ybg, normBG) - Math.pow(Ytxt, normTXT)) * scaleBoW; + out = sapc < loClip ? 0 : sapc - loBoWoffset; + } else { + const sapc = (Math.pow(Ybg, revBG) - Math.pow(Ytxt, revTXT)) * scaleWoB; + out = sapc > -loClip ? 0 : sapc + loWoBoffset; + } + return out * 100; +} + +// deltaE-OK: Euclidean distance in OKLab. +function deltaE(aHex, bHex) { + const x = srgb2oklab(aHex), y = srgb2oklab(bHex); + return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b); +} + +// --- WCAG 2.x relative luminance + contrast (migrated from the page inline) --- +// rl reuses the canonical lin() above. On 8-bit channels lin's 0.04045 cutoff is +// byte-identical to the WCAG 0.03928 piecewise the inline copy used — no channel +// value falls between the two thresholds (10/255 = 0.0392, 11/255 = 0.0431) — so +// every #rrggbb contrast value is preserved exactly. +function rl(hex) { + const [R, G, B] = hex2rgb(hex); + return 0.2126 * lin(R / 255) + 0.7152 * lin(G / 255) + 0.0722 * lin(B / 255); +} + +function contrast(aHex, bHex) { + const L1 = rl(aHex), L2 = rl(bHex), hi = Math.max(L1, L2), lo = Math.min(L1, L2); + return (hi + 0.05) / (lo + 0.05); +} + +function rating(r) { return r >= 7 ? 'AAA' : r >= 4.5 ? 'AA' : 'FAIL'; } + +// --- HSV <-> sRGB for the color picker (migrated from the page inline) --- +function hsv2rgb(h, s, v) { + h = (h % 360 + 360) % 360 / 360; + const i = Math.floor(h * 6), f = h * 6 - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + let r, g, b; + switch (((i % 6) + 6) % 6) { + case 0: [r, g, b] = [v, t, p]; break; + case 1: [r, g, b] = [q, v, p]; break; + case 2: [r, g, b] = [p, v, t]; break; + case 3: [r, g, b] = [p, q, v]; break; + case 4: [r, g, b] = [t, p, v]; break; + default: [r, g, b] = [v, p, q]; + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function rgb2hsv(r, g, b) { + r /= 255; g /= 255; b /= 255; + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; + let h = 0; + if (d) { + if (mx === r) h = ((g - b) / d + 6) % 6; + else if (mx === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + } + return [h, mx ? d / mx : 0, mx]; +} + +function rgb2hex(r, g, b) { + return '#' + [r, g, b].map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join(''); +} function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} -function contrast(a,b){const L1=rl(a),L2=rl(b),hi=Math.max(L1,L2),lo=Math.min(L1,L2);return (hi+0.05)/(lo+0.05);} -function rating(r){return r>=7?'AAA':r>=4.5?'AA':'FAIL';} function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} function cid(l){return l.replace(/\W/g,'');} function buildLangSel(){const s=document.getElementById('langsel');s.innerHTML='';for(const lang in SAMPLES){const o=document.createElement('option');o.value=lang;o.textContent=lang;s.appendChild(o);}} @@ -232,10 +399,6 @@ function updateColor(){ } function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} -function hsv2rgb(h,s,v){h=(h%360+360)%360/360;const i=Math.floor(h*6),f=h*6-i,p=v*(1-s),q=v*(1-f*s),t=v*(1-(1-f)*s);let r,g,b;switch(((i%6)+6)%6){case 0:[r,g,b]=[v,t,p];break;case 1:[r,g,b]=[q,v,p];break;case 2:[r,g,b]=[p,v,t];break;case 3:[r,g,b]=[p,q,v];break;case 4:[r,g,b]=[t,p,v];break;default:[r,g,b]=[v,p,q];}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)];} -function rgb2hsv(r,g,b){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;let h=0;if(d){if(mx===r)h=((g-b)/d+6)%6;else if(mx===g)h=(b-r)/d+2;else h=(r-g)/d+4;h*=60;}return[h,mx?d/mx:0,mx];} -function hex2rgb(h){return[parseInt(h.substr(1,2),16),parseInt(h.substr(3,2),16),parseInt(h.substr(5,2),16)];} -function rgb2hex(r,g,b){return '#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; function pkThresh(){return pkMode==='aa'?4.5:pkMode==='aaa'?7:0;} |
