aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-09 18:58:10 -0500
committerCraig Jennings <c@cjennings.net>2026-06-09 18:58:10 -0500
commit9da6c6635afafe4f2eae51d4bdd20dbc41856e27 (patch)
treed50c6cd6017baf6542085a89ce1396354d0e4877 /scripts
parente7021bfe47072d8d9cb0fa6ec8d240d877f13cf0 (diff)
downloaddotemacs-9da6c6635afafe4f2eae51d4bdd20dbc41856e27.tar.gz
dotemacs-9da6c6635afafe4f2eae51d4bdd20dbc41856e27.zip
feat(theme-studio): add the ramp UI in the palette
A ramp button on the palette controls opens a panel that generates a tonal ramp from the current color and previews the steps. Each step is a swatch labeled with its derived name (blue, blue+1, blue-1) and a clamp badge when the color left the sRGB gamut, so an out-of-gamut step is visible before it's added. The n, stepL, and chroma-ease controls default to 2 / 0.08 / 0.5 and re-preview live. Clicking a step adds it to the palette; "add all" adds the lot. Steps insert adjacent to the source swatch in -n..+n order. A name collision is flagged and skipped rather than overwriting an existing color, and a generated hex that already matches another entry is added but flagged as a duplicate. This is Phase 2, the DOM around the pure ramp() from Phase 1. A new #ramptest browser gate pins the step count, the ordered insertion after the source, the collision skip, and the clamp badge on an out-of-gamut step.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/app.js59
-rw-r--r--scripts/theme-studio/generate.py14
-rwxr-xr-xscripts/theme-studio/run-tests.sh2
-rw-r--r--scripts/theme-studio/styles.css7
-rw-r--r--scripts/theme-studio/theme-studio.html80
5 files changed, 161 insertions, 1 deletions
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index d0fed81c..52bc4b6f 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -267,6 +267,48 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
if(!name){notify('name the color before adding it',true);return;}
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
+// --- ramp generator UI (palette-ramps spec, Phase 2) -------------------------
+// Generate a tonal ramp from the current color, preview the steps, add the ones
+// you want as named palette entries. The pure ramp() lives in app-core.js; this
+// is the DOM around it. Names derive from the source swatch (blue -> blue+1).
+let rampBase=null; // {hex,name} the ramp is generated from
+function openRamp(){const hex=curHex();const name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';rampBase={hex,name};document.getElementById('rampname').textContent=name+' '+hex;document.getElementById('ramp').style.display='block';renderRamp();}
+function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';}
+function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};}
+function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));}
+function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';}
+function renderRamp(){
+ if(!rampBase)return;
+ const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML='';
+ if(r.error){rampNote('not a valid base color',true);return;}
+ rampNote(r.adjusted.length?('adjusted: '+r.adjusted.join(', ')):'',false);
+ r.steps.forEach(s=>{const nm=rampStepName(s.offset);const c=document.createElement('div');c.className='rchip';c.style.background=s.hex;c.style.color=textOn(s.hex);
+ c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'');
+ c.innerHTML=`<span>${esc(nm)}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}`;
+ c.onclick=()=>addRampStep(s);prev.appendChild(c);});
+}
+// Insert a step adjacent to the source swatch, keeping the ramp siblings in
+// -n..+n order. A name collision is flagged and skipped (never overwrites); a
+// hex that already exists under another name is added but flagged as a duplicate.
+function rampInsertIndex(off){
+ const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$');
+ let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1;
+ let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;}
+ return idx;
+}
+function addRampStep(s){
+ const nm=rampStepName(s.offset);
+ if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;}
+ const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase());
+ PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);renderPalette();buildTable();buildUITable();
+ rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true;
+}
+function addAllRampSteps(){
+ if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts());
+ if(r.error){rampNote('not a valid base color',true);return;}
+ let added=0,skipped=0;r.steps.forEach(s=>{addRampStep(s)?added++:skipped++;});
+ rampNote('added '+added+(skipped?(', skipped '+skipped+' (name exists)'):''),false);
+}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
@@ -914,3 +956,20 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2;
const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);}
+// Ramp UI gate (open with #ramptest): generation count, ordered insertion after
+// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step.
+if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const save=PALETTE.slice();
+ PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette();
+ selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue';
+ openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp();
+ A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length);
+ addAllRampSteps();
+ const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue');
+ A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(','));
+ const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names');
+ rampBase={hex:'#2040e0',name:'vivid'};document.getElementById('rampname').textContent='vivid';document.getElementById('rampce').value='0';renderRamp();
+ A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step');
+ PALETTE=save;selectedIdx=null;renderPalette();closeRamp();
+ document.title='RAMPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index 9315faad..60520b41 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -448,7 +448,21 @@ STYLES_CSS</style>
<input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()">
<button onclick="addColor()">+ add color</button>
<button onclick="updateColor()">&#8635; update selected</button>
+ <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">&#9968; ramp</button>
<span id="palmsg"></span>
+ <div id="ramp" class="ramp" style="display:none">
+ <div class="ramprow">
+ <label>ramp from <b id="rampname">&mdash;</b></label>
+ <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label>
+ <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label>
+ <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label>
+ <button onclick="renderRamp()">preview</button>
+ <button onclick="addAllRampSteps()">+ add all</button>
+ <button onclick="closeRamp()">close</button>
+ </div>
+ <div id="rampprev" class="rampprev"></div>
+ <div id="rampmsg"></div>
+ </div>
<div id="picker" class="picker">
<div class="prow">
<div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div>
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index e364d431..2f074fe3 100755
--- a/scripts/theme-studio/run-tests.sh
+++ b/scripts/theme-studio/run-tests.sh
@@ -53,7 +53,7 @@ CHROME=""
for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi
done
-HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest"
+HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest"
if [ "$NO_BROWSER" = 1 ]; then
skip_msg "browser hash gates (--no-browser)"
elif [ -z "$CHROME" ]; then
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index 72541ca0..6e860059 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -62,6 +62,13 @@
.pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad}
.pinfo2 span{cursor:default}
.pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer}
+ .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412}
+ .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2}
+ .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace}
+ .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
+ .rchip{width:74px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace}
+ .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px}
+ #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496}
.palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer}
#palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 3fc8bdb1..bdde3091 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -64,6 +64,13 @@
.pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad}
.pinfo2 span{cursor:default}
.pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer}
+ .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412}
+ .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2}
+ .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace}
+ .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
+ .rchip{width:74px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace}
+ .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px}
+ #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496}
.palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer}
#palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
@@ -100,7 +107,21 @@
<input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()">
<button onclick="addColor()">+ add color</button>
<button onclick="updateColor()">&#8635; update selected</button>
+ <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">&#9968; ramp</button>
<span id="palmsg"></span>
+ <div id="ramp" class="ramp" style="display:none">
+ <div class="ramprow">
+ <label>ramp from <b id="rampname">&mdash;</b></label>
+ <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label>
+ <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label>
+ <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label>
+ <button onclick="renderRamp()">preview</button>
+ <button onclick="addAllRampSteps()">+ add all</button>
+ <button onclick="closeRamp()">close</button>
+ </div>
+ <div id="rampprev" class="rampprev"></div>
+ <div id="rampmsg"></div>
+ </div>
<div id="picker" class="picker">
<div class="prow">
<div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div>
@@ -767,6 +788,48 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
if(!name){notify('name the color before adding it',true);return;}
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
+// --- ramp generator UI (palette-ramps spec, Phase 2) -------------------------
+// Generate a tonal ramp from the current color, preview the steps, add the ones
+// you want as named palette entries. The pure ramp() lives in app-core.js; this
+// is the DOM around it. Names derive from the source swatch (blue -> blue+1).
+let rampBase=null; // {hex,name} the ramp is generated from
+function openRamp(){const hex=curHex();const name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';rampBase={hex,name};document.getElementById('rampname').textContent=name+' '+hex;document.getElementById('ramp').style.display='block';renderRamp();}
+function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';}
+function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};}
+function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));}
+function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';}
+function renderRamp(){
+ if(!rampBase)return;
+ const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML='';
+ if(r.error){rampNote('not a valid base color',true);return;}
+ rampNote(r.adjusted.length?('adjusted: '+r.adjusted.join(', ')):'',false);
+ r.steps.forEach(s=>{const nm=rampStepName(s.offset);const c=document.createElement('div');c.className='rchip';c.style.background=s.hex;c.style.color=textOn(s.hex);
+ c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'');
+ c.innerHTML=`<span>${esc(nm)}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}`;
+ c.onclick=()=>addRampStep(s);prev.appendChild(c);});
+}
+// Insert a step adjacent to the source swatch, keeping the ramp siblings in
+// -n..+n order. A name collision is flagged and skipped (never overwrites); a
+// hex that already exists under another name is added but flagged as a duplicate.
+function rampInsertIndex(off){
+ const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$');
+ let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1;
+ let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;}
+ return idx;
+}
+function addRampStep(s){
+ const nm=rampStepName(s.offset);
+ if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;}
+ const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase());
+ PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);renderPalette();buildTable();buildUITable();
+ rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true;
+}
+function addAllRampSteps(){
+ if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts());
+ if(r.error){rampNote('not a valid base color',true);return;}
+ let added=0,skipped=0;r.steps.forEach(s=>{addRampStep(s)?added++:skipped++;});
+ rampNote('added '+added+(skipped?(', skipped '+skipped+' (name exists)'):''),false);
+}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
@@ -1414,4 +1477,21 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2;
const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);}
+// Ramp UI gate (open with #ramptest): generation count, ordered insertion after
+// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step.
+if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const save=PALETTE.slice();
+ PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette();
+ selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue';
+ openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp();
+ A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length);
+ addAllRampSteps();
+ const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue');
+ A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(','));
+ const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names');
+ rampBase={hex:'#2040e0',name:'vivid'};document.getElementById('rampname').textContent='vivid';document.getElementById('rampce').value='0';renderRamp();
+ A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step');
+ PALETTE=save;selectedIdx=null;renderPalette();closeRamp();
+ document.title='RAMPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
</script> \ No newline at end of file