aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/theme-studio.html
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/theme-studio.html')
-rw-r--r--scripts/theme-studio/theme-studio.html179
1 files changed, 171 insertions, 8 deletions
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
-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;}