aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-16 06:11:15 -0500
committerCraig Jennings <c@cjennings.net>2026-06-16 06:11:15 -0500
commit9e99749de911ffc5b375bc79ee664d498e4d76d6 (patch)
treef76652c5d684536598f8d510baf52f304c70d818
parentcf882dfe168463471598d01205256131bc4e7f1b (diff)
downloaddotemacs-9e99749de911ffc5b375bc79ee664d498e4d76d6.tar.gz
dotemacs-9e99749de911ffc5b375bc79ee664d498e4d76d6.zip
feat(theme-studio): move the contrast verdict into a hover
The contrast column showed "5.4 PASS". The number's color already encodes the tier (green AAA, grey AA, red fail), so the PASS/FAIL word was redundant. I dropped it and put the WCAG meaning in the cell's hover via a pure contrastTitle helper. crHtml now renders just the colored number. verdictFor stays for the covered-overlay worst-case readout, which is unchanged.
-rw-r--r--scripts/theme-studio/app-util.js12
-rw-r--r--scripts/theme-studio/app.js2
-rw-r--r--scripts/theme-studio/browser-gates.js11
-rw-r--r--scripts/theme-studio/test-app-util.mjs27
-rw-r--r--scripts/theme-studio/theme-studio.html23
5 files changed, 71 insertions, 4 deletions
diff --git a/scripts/theme-studio/app-util.js b/scripts/theme-studio/app-util.js
index e3f76dd88..774553012 100644
--- a/scripts/theme-studio/app-util.js
+++ b/scripts/theme-studio/app-util.js
@@ -17,4 +17,14 @@ function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
// Pick black or white text for a background hex, by WCAG relative luminance.
function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
-export { normHex, ratingColor, textOn };
+// Hover text for a contrast ratio. The number's color already encodes the tier
+// (ratingColor: green AAA, grey AA, red fail), so the cell drops the PASS/FAIL
+// word and this explains the color on hover.
+function contrastTitle(r){
+ const n=r.toFixed(1)+':1';
+ if(r>=7) return n+' (green): passes WCAG AA and AAA';
+ if(r>=4.5) return n+' (grey): passes WCAG AA, not AAA';
+ return n+' (red): fails WCAG AA';
+}
+
+export { normHex, ratingColor, textOn, contrastTitle };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 23307edac..07ca06fe1 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -35,7 +35,7 @@ PALETTE_GENERATOR_UI_J
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
// table verdict. Callers compute r for their own fg/bg.
function verdictFor(r,target=4.5){return r>=target?'PASS':'FAIL';}
-function crHtml(r,target=4.5){const v=verdictFor(r,target);return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${v}</span>`;}
+function crHtml(r){return `<span style="color:${ratingColor(r)}" title="${esc(contrastTitle(r))}">${r.toFixed(1)}</span>`;}
// Effective fg/bg with the standard fallback: an unset foreground reads as the
// default fg (MAP['p']), an unset background as the ground (MAP['bg']). All three
// tiers resolve their raw value through these before measuring or rendering.
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index d0986b56b..7c8b05d3f 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -719,6 +719,17 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
document.title='NDTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Contrast-cell gate (open with #crtest): the per-face contrast column shows a
+// bare colored number (no PASS/FAIL word); the WCAG verdict lives in the hover.
+if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
+ const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[5];
+ const span=cell&&cell.querySelector('span');
+ A(span&&/^\d+\.\d$/.test(span.textContent.trim()),'contrast cell is a bare number: '+(span&&span.textContent));
+ A(span&&!/PASS|FAIL/.test(span.textContent),'no PASS/FAIL word in the contrast cell');
+ A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title));
+ document.title='CRTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
diff --git a/scripts/theme-studio/test-app-util.mjs b/scripts/theme-studio/test-app-util.mjs
index 2cb08e0e6..37cf0889b 100644
--- a/scripts/theme-studio/test-app-util.mjs
+++ b/scripts/theme-studio/test-app-util.mjs
@@ -5,10 +5,35 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-import { normHex, ratingColor, textOn } from './app-util.js';
+import { normHex, ratingColor, textOn, contrastTitle } from './app-util.js';
const here = fileURLToPath(new URL('.', import.meta.url));
+// contrastTitle: the hover text for a contrast number, naming the color tier
+// (green AAA / grey AA / red fail) so the verdict word can be dropped from the cell.
+test('contrastTitle: green tier (>=7) names AAA', () => {
+ const t = contrastTitle(8.2);
+ assert.match(t, /green/i);
+ assert.match(t, /AAA/);
+ assert.match(t, /^8\.2:1/);
+});
+test('contrastTitle: grey tier (4.5..7) passes AA, not AAA', () => {
+ const t = contrastTitle(5.4);
+ assert.match(t, /grey|gray/i);
+ assert.match(t, /AA/);
+ assert.match(t, /not AAA|below AAA/i);
+});
+test('contrastTitle: red tier (<4.5) fails AA', () => {
+ const t = contrastTitle(3.1);
+ assert.match(t, /red/i);
+ assert.match(t, /fail/i);
+});
+test('contrastTitle: boundaries — 7 is green, 4.5 is grey, just under is red', () => {
+ assert.match(contrastTitle(7), /green/i);
+ assert.match(contrastTitle(4.5), /grey|gray/i);
+ assert.match(contrastTitle(4.49), /red/i);
+});
+
test('normHex: Normal — adds the #, lowercases, accepts an existing #', () => {
assert.equal(normHex('67809C'), '#67809c');
assert.equal(normHex('#E8BD30'), '#e8bd30');
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index fdc5be974..c4dd7149d 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -964,6 +964,16 @@ function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
// Pick black or white text for a background hex, by WCAG relative luminance.
function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
+
+// Hover text for a contrast ratio. The number's color already encodes the tier
+// (ratingColor: green AAA, grey AA, red fail), so the cell drops the PASS/FAIL
+// word and this explains the color on hover.
+function contrastTitle(r){
+ const n=r.toFixed(1)+':1';
+ if(r>=7) return n+' (green): passes WCAG AA and AAA';
+ if(r>=4.5) return n+' (grey): passes WCAG AA, not AAA';
+ return n+' (red): fails WCAG AA';
+}
// Pure palette-generator planner and browser-side generator panel.
// Pure palette-generator planner. It depends on the shared palette-column model
// from app-core.js, but owns candidate hue selection, naming, contrast filtering,
@@ -1382,7 +1392,7 @@ function initGeneratorControls(){
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
// table verdict. Callers compute r for their own fg/bg.
function verdictFor(r,target=4.5){return r>=target?'PASS':'FAIL';}
-function crHtml(r,target=4.5){const v=verdictFor(r,target);return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${v}</span>`;}
+function crHtml(r){return `<span style="color:${ratingColor(r)}" title="${esc(contrastTitle(r))}">${r.toFixed(1)}</span>`;}
// Effective fg/bg with the standard fallback: an unset foreground reads as the
// default fg (MAP['p']), an unset background as the ground (MAP['bg']). All three
// tiers resolve their raw value through these before measuring or rendering.
@@ -3369,6 +3379,17 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
document.title='NDTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Contrast-cell gate (open with #crtest): the per-face contrast column shows a
+// bare colored number (no PASS/FAIL word); the WCAG verdict lives in the hover.
+if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
+ const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[5];
+ const span=cell&&cell.querySelector('span');
+ A(span&&/^\d+\.\d$/.test(span.textContent.trim()),'contrast cell is a bare number: '+(span&&span.textContent));
+ A(span&&!/PASS|FAIL/.test(span.textContent),'no PASS/FAIL word in the contrast cell');
+ A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title));
+ document.title='CRTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.