aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/palette-generator-ui.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-14 18:14:29 -0500
committerCraig Jennings <c@cjennings.net>2026-06-14 18:14:29 -0500
commit214c16fe127e007965b21d38d0c9c24f8c995b4c (patch)
treeb4a54af6a928122c3f8af374618b07da5fc798c8 /scripts/theme-studio/palette-generator-ui.js
parent814f7dbf74af01e7932be7a994ecc8297e843f37 (diff)
downloaddotemacs-214c16fe127e007965b21d38d0c9c24f8c995b4c.tar.gz
dotemacs-214c16fe127e007965b21d38d0c9c24f8c995b4c.zip
feat(theme-studio): palette generator and preview fidelity
Two strands land together because the generated theme-studio.html bundles every source file into one page and can't be split cleanly. The palette generator is a preview-first panel: palette-generator-core.js plans the palette and palette-generator-ui.js draws it. Generated colors stay inspectable and tunable through the existing selector, and committing one creates a normal base column. It adds source-mode and scheme controls, a configurable accent count, and color names from color-names.json. For preview fidelity, syntax and UI colors now resolve through the real Emacs inherit chains, so the preview matches how Emacs renders the theme. resolveSyntaxFg pins dec to ty (Emacs has no decorator face) and otherwise follows comment-delimiter to comment, doc to string, property to variable, function-call to function-name. resolveUiAttr walks mode-line-inactive to mode-line and line-number-current-line to line-number. The decorator label now reads "decorator to type" to match the type face Emacs uses for it. Design recorded in the two theme-studio specs under docs/.
Diffstat (limited to 'scripts/theme-studio/palette-generator-ui.js')
-rw-r--r--scripts/theme-studio/palette-generator-ui.js152
1 files changed, 152 insertions, 0 deletions
diff --git a/scripts/theme-studio/palette-generator-ui.js b/scripts/theme-studio/palette-generator-ui.js
new file mode 100644
index 000000000..6cab65508
--- /dev/null
+++ b/scripts/theme-studio/palette-generator-ui.js
@@ -0,0 +1,152 @@
+// Browser-side palette-generator panel. The pure planner lives in
+// palette-generator-core.js; this file only gathers controls, renders previews,
+// and commits selected generated colors into normal palette entries.
+let GEN_PROPOSAL=null, GEN_SELECTION=null;
+const GENERATOR_CONTROLS={
+ genintent:{
+ labelTitle:'what kind of candidate colors to look for',
+ options:[
+ ['random','random','Pure exploration: reroll unrelated candidate base colors.'],
+ ['near-palette','near palette','Generate candidates near the current palette base colors.'],
+ ['fill-gaps','fill gaps','Find missing perceptual colors using OKLab distance.'],
+ ['fill-hue-gaps','fill hue gaps','Find missing perceptual colors while rewarding underrepresented hue regions.'],
+ ['complements','complements','Generate colors opposite palette or selected anchors.'],
+ ['bridges','bridges','Generate colors between existing palette anchors.'],
+ ['near-selected','near selected','Generate candidates near the current selector color.'],
+ ['complementary','complementary','Use the hue opposite the anchor.'],
+ ['analogous','analogous','Use neighboring hues around the anchor.'],
+ ['split-complementary','split complementary','Use hues on both sides of the anchor complement.'],
+ ['triadic','triadic','Use three hues spaced 120 degrees apart.'],
+ ['tetradic','tetradic','Use two complementary hue pairs.'],
+ ['square','square','Use four hues spaced 90 degrees apart.'],
+ ['monochromatic','monochromatic','Stay near one hue and vary color character.'],
+ ['rainbow','rainbow','Spread candidates evenly around the full hue wheel.'],
+ ],
+ },
+ genvibe:{
+ labelTitle:'the character of generated candidate colors',
+ options:[
+ ['bold','bold','Higher chroma, assertive candidate colors.'],
+ ['balanced','balanced','Moderate chroma candidate colors.'],
+ ['muted','muted','Lower chroma, quieter candidate colors.'],
+ ['pastel','pastel','Light, low-chroma candidate colors.'],
+ ['deep','deep','Lower-lightness, richer candidate colors.'],
+ ['jewel','jewel','Saturated, rich candidate colors.'],
+ ['earthy','earthy','Warmer, reduced-chroma earth-tone candidates.'],
+ ['neon','neon','Very high-chroma candidate colors.'],
+ ['strange','strange','More unusual, high-variance candidate colors.'],
+ ['warm','warm','Bias candidates toward red, orange, and yellow.'],
+ ['cool','cool','Bias candidates toward green, cyan, blue, and violet.'],
+ ],
+ },
+ gensource:{
+ labelTitle:'where starting hues come from',
+ options:[
+ ['palette','palette','Use current base color columns as anchors; span tiles are ignored.'],
+ ['none','none','Use no anchors; useful for pure random exploration.'],
+ ['bg-fg','bg/fg','Use the current background and foreground as anchors.'],
+ ['selected','selected','Use the current selector tile as the anchor.'],
+ ],
+ },
+ gencontrast:{
+ labelTitle:'minimum contrast against the current bg',
+ options:[
+ ['aa','AA','Require WCAG AA contrast against the current background.'],
+ ['aaa','AAA','Require WCAG AAA contrast against the current background.'],
+ ['none','none','Do not reject candidates by WCAG contrast.'],
+ ],
+ },
+};
+function generatorOptionTitle(id,value){
+ const ctl=GENERATOR_CONTROLS[id];
+ const row=ctl&&ctl.options.find(o=>o[0]===value);
+ return row?row[2]:'';
+}
+function populateGeneratorSelects(){
+ Object.entries(GENERATOR_CONTROLS).forEach(([id,ctl])=>{
+ const el=document.getElementById(id);if(!el)return;
+ const cur=el.value||el.dataset.default||ctl.options[0][0];
+ el.innerHTML='';
+ ctl.options.forEach(([value,label])=>{const o=document.createElement('option');o.value=value;o.textContent=label;el.appendChild(o);});
+ el.value=ctl.options.some(o=>o[0]===cur)?cur:ctl.options[0][0];
+ const label=el.closest('label');if(label)label.title=ctl.labelTitle;
+ });
+}
+function genConfig(){
+ const intent=document.getElementById('genintent'),vibe=document.getElementById('genvibe'),
+ source=document.getElementById('gensource'),
+ accents=document.getElementById('genaccents'),contrastSel=document.getElementById('gencontrast');
+ return {
+ intent:intent?intent.value:'random',
+ vibe:vibe?vibe.value:'bold',
+ sourceMode:source?source.value:'palette',
+ scheme:'random',
+ baseHue:250,
+ accentCount:accents?parseInt(accents.value,10):5,
+ spanCount:0,
+ contrastMode:contrastSel?contrastSel.value:'aa',
+ selectedHex:curHex(),
+ colorNames:COLOR_NAMES,
+ };
+}
+function syncGeneratorControls(){syncGeneratorSelectTitles();}
+function syncGeneratorSelectTitles(){
+ Object.keys(GENERATOR_CONTROLS).forEach(id=>{const el=document.getElementById(id);if(el)el.title=generatorOptionTitle(id,el.value);});
+}
+function setGenMessage(msg,err){const m=document.getElementById('genmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';}
+function renderGeneratorPreview(){
+ const host=document.getElementById('genpreview');if(!host)return;host.innerHTML='';
+ if(!GEN_PROPOSAL){setGenMessage('',false);return;}
+ GEN_PROPOSAL.columns.forEach((col,ci)=>{
+ const strip=document.createElement('div');strip.className='gencol';
+ const head=document.createElement('div');head.className='genhead';
+ head.innerHTML=`<span title="${esc(col.name)}">${esc(col.name)}</span><button class="genappend" data-col="${ci}" title="append this generated column to the palette">+</button>`;
+ head.querySelector('.genappend').onclick=()=>appendGeneratedColumn(ci);
+ strip.appendChild(head);
+ col.members.forEach((m,mi)=>{
+ const chip=document.createElement('div'),tc=textOn(m.hex);
+ chip.className='genchip'+(GEN_SELECTION&&GEN_SELECTION.hex===m.hex&&GEN_SELECTION.name===m.name?' sel':'');
+ chip.dataset.col=String(ci);chip.dataset.member=String(mi);chip.dataset.hex=m.hex;chip.dataset.name=m.name;
+ chip.style.background=m.hex;chip.style.color=tc;chip.title=m.name+' '+m.hex+(m.clamped?' (sRGB clamped)':'');
+ chip.innerHTML=`<div class="gn">${esc(m.name.replace(/-/g,' '))}</div><div class="gh">${m.hex}</div>`;
+ chip.onclick=()=>selectGeneratedTile(ci,mi);
+ strip.appendChild(chip);
+ });
+ host.appendChild(strip);
+ });
+ const s=GEN_PROPOSAL.summary;
+ setGenMessage(s.generated+' column(s) previewed'+(s.rejected?(', '+s.rejected+' rejected'):'')+(s.minContrast?(', min '+s.minContrast.toFixed(1)+':1'):''),false);
+}
+function resetGeneratorPreviewState(){
+ GEN_PROPOSAL=null;GEN_SELECTION=null;
+ renderGeneratorPreview();
+}
+function previewGenerator(){
+ const cfg=genConfig();
+ resetGeneratorPreviewState();
+ GEN_PROPOSAL=planPaletteGenerator(PALETTE,{bg:MAP['bg'],fg:MAP['p']},cfg);
+ renderGeneratorPreview();
+}
+function clearGeneratorPreview(){resetGeneratorPreviewState();}
+function selectGeneratedTile(ci,mi){
+ if(!GEN_PROPOSAL||!GEN_PROPOSAL.columns[ci])return;
+ const m=GEN_PROPOSAL.columns[ci].members[mi];if(!m)return;
+ selectedIdx=null;GEN_SELECTION={column:ci,member:mi,hex:m.hex,name:m.name};
+ setHex(m.hex);document.getElementById('newname').value=m.name;
+ renderPalette();renderGeneratorPreview();
+ notify('loaded generated "'+m.name+'" into the selector - add it to commit',false);
+}
+function appendGeneratedColumn(ci){
+ if(!GEN_PROPOSAL||!GEN_PROPOSAL.columns[ci])return;
+ const colName=GEN_PROPOSAL.columns[ci].name, entries=entriesForGeneratedColumn(GEN_PROPOSAL.columns[ci]);
+ const existing=new Set(PALETTE.map(p=>(p[1]||'').toLowerCase()));
+ if(entries.some(e=>existing.has(e[1].toLowerCase()))){notify('generated names already exist - preview again for fresh names',true);return;}
+ PALETTE.push(...entries);GEN_SELECTION=null;selectedIdx=null;
+ refreshPaletteState();previewGenerator();
+ notify('added generated column "'+colName+'"',false);
+}
+function initGeneratorControls(){
+ populateGeneratorSelects();
+ Object.keys(GENERATOR_CONTROLS).forEach(id=>{const el=document.getElementById(id);if(el)el.onchange=syncGeneratorControls;});
+ syncGeneratorControls();
+}