diff options
Diffstat (limited to 'scripts/theme-studio/app.js')
| -rw-r--r-- | scripts/theme-studio/app.js | 785 |
1 files changed, 213 insertions, 572 deletions
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index aadfd5b7..45b1f486 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -1,7 +1,16 @@ const SAMPLES=SAMPLES_J, CATS=CATS_J, UI_FACES=UIFACES_J, APPS=APPS_J; -let MAP=MAP_J, PALETTE=PALETTE_J, BOLD=BOLD_J, ITALIC=ITALIC_J, UIMAP=UIMAP_J; -let LOCKED=new Set(LOCKS_J); // syntax categories whose element↔color is decided (dropdown disabled, skipped by clear-unlocked) +let MAP=MAP_J, PALETTE=PALETTE_J, SYNTAX=SYNTAX_J, UIMAP=UIMAP_J; +let LOCKED=new Set(LOCKS_J); // rows whose choice is decided (controls disabled, skipped by erase/reset batch actions) const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec) +const DEFAULT_UIMAP=JSON.parse(JSON.stringify(UIMAP)); +function syntaxBlank(k){return {fg:MAP[k]||null,bg:null,bold:false,italic:false,underline:false,strike:false,box:null};} +function syncSyntaxCache(k){const s=SYNTAX[k]||syntaxBlank(k);MAP[k]=s.fg||'';} +function syncAllSyntaxCache(){CATS.forEach(c=>syncSyntaxCache(c[0]));} +function syncSyntaxFromCache(){CATS.forEach(c=>{const k=c[0];syntaxFace(k).fg=MAP[k]||null;});} +function syntaxFace(k){if(!SYNTAX[k])SYNTAX[k]=syntaxBlank(k);return SYNTAX[k];} +function setSyntaxFg(k,hex){syntaxFace(k).fg=hex||null;syncSyntaxCache(k);} +syncAllSyntaxCache(); +const DEFAULT_SYNTAX=JSON.parse(JSON.stringify(SYNTAX)); // --- tier-3 package faces: pure state helpers (Phase 1) --- // Thin wrappers over the pure logic in app-core.js (inlined further down), // passing the live module state. packagesForExport / mergePackagesInto live in @@ -33,8 +42,7 @@ function renderCode(){ const lang=document.getElementById('langsel').value;let html=''; for(const line of SAMPLES[lang]){ if(line.length===0){html+='\n';continue;} - for(const [k,t] of line){const c=effFg(MAP[k])||'#cdced1';const w=BOLD[k]?'bold':'normal';const s=ITALIC[k]?'italic':'normal'; - html+=`<span data-k="${k}" style="color:${c};font-weight:${w};font-style:${s}">${esc(t)}</span>`;} + for(const [k,t] of line)html+=`<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`; html+='\n';} const cp=document.getElementById('codepre');cp.innerHTML=html; cp.onclick=(e)=>{const s=e.target.closest('[data-k]');if(s)flashAssign(s.dataset.k);}; @@ -46,13 +54,25 @@ function renderCode(){ let _ddPop=null; function closeColorDropdown(){if(_ddPop){_ddPop.remove();_ddPop=null;}} document.addEventListener('pointerdown',e=>{if(_ddPop&&!e.target.closest('.cdd')&&!e.target.closest('.cddpop'))closeColorDropdown();}); -function mkColorDropdown(options,cur,onPick){ - const t=document.createElement('div');t.className='cdd';t.tabIndex=0; +function mkColorDropdown(options,cur,onPick,opts={}){ + const wrap=document.createElement('div');wrap.className='cstep'; + const left=document.createElement('button'),right=document.createElement('button'); + left.className='cstepbtn';right.className='cstepbtn';left.type=right.type='button'; + left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column'; + const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0; const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');}; - function paint(){t.style.background=cur||'#161412';t.style.color=cur?textOn(cur):'#b4b1a2';t.dataset.val=cur||''; - t.innerHTML=`<span class="cddsw" style="background:${cur||'transparent'}"></span>${esc(nameOf(cur))}`;} + function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']},dir);if(!next)return;cur=next;paint();onPick(next);} + function paintStepButtons(){ + const locked=wrap.dataset.locked==='1'; + left.disabled=locked||!spanNeighborHex(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']},-1); + right.disabled=locked||!spanNeighborHex(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']},1); + } + function paint(){const nm=nameOf(cur),ttl=cur?(nm+' '+cur):nm;t.style.background=cur||'#161412';t.style.color=cur?textOn(cur):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur); + t.innerHTML=opts.compact?`<span class="cddsw" style="background:${cur||'transparent'}"></span>`:`<span class="cddsw" style="background:${cur||'transparent'}"></span>${esc(nm)}`;paintStepButtons();} paint(); - t.onclick=(e)=>{e.stopPropagation();if(t.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;} + left.onclick=e=>{e.stopPropagation();step(-1);}; + right.onclick=e=>{e.stopPropagation();step(1);}; + t.onclick=(e)=>{e.stopPropagation();if(wrap.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;} const pop=document.createElement('div');pop.className='cddpop'; for(const [hex,name] of options){const row=document.createElement('div');row.className='cddrow'+(hex===cur?' sel':''); row.innerHTML=`<span class="cddsw" style="background:${hex||'transparent'}"></span><span class="cddnm">${esc(name)}</span><span class="cddhx">${hex||''}</span>`; @@ -65,11 +85,15 @@ function mkColorDropdown(options,cur,onPick){ if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px'; _ddPop=pop;}; t.setValue=h=>{cur=h;paint();}; - return t;} + wrap.setValue=h=>{cur=h;paint();}; + wrap.syncLocked=paintStepButtons; + wrap.appendChild(left);wrap.appendChild(t);wrap.appendChild(right);paintStepButtons(); + return wrap;} // Standard option list for a swatch dropdown: a "default" entry, then the -// palette. If cur is set but no longer in the palette, surface it as a "(gone)" -// entry so the row still shows what it points at. Shared by all three tiers. -function ddList(cur){return optList(cur,PALETTE);} +// palette in the same ground/column order as the palette panel. If cur is set +// but no longer in the palette, surface it as a "(gone)" entry so the row still +// shows what it points at. Shared by all three tiers. +function ddList(cur){return paletteOptionList(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']});} // Shared lock toggle for any table row. lockKey is namespaced per tier (bare // syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable // controls — native selects/buttons/inputs are disabled; the custom swatch @@ -81,8 +105,8 @@ function mkLockCell(lockKey,els){ lk.title=on?'locked — click to unlock':'click to lock this decision'; (els||[]).forEach(el=>{if(!el)return; if(el.tagName==='SELECT'||el.tagName==='BUTTON'||el.tagName==='INPUT')el.disabled=on; - else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);}});} - lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();}; + else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);if(el.syncLocked)el.syncLocked();}});} + lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();updateLockToggles();}; paint();td.appendChild(lk);return td;} // B/I/U/S style buttons shared by the UI and package tables. isOn(attr) reads the // current state of an attribute, onToggle(attr) flips it and repaints. Returns @@ -92,186 +116,117 @@ function mkStyleButtons(isOn,onToggle){ const b=document.createElement('button');b.className='sbtn'+(isOn(at)?' on':'');b.textContent='a'; b.style.fontWeight=at==='bold'?'bold':'normal';b.style.fontStyle=at==='italic'?'italic':'normal'; b.style.textDecoration=at==='underline'?'underline':at==='strike'?'line-through':'none';b.title=at; - b.onclick=()=>onToggle(at);return b;});} -// Reset every unlocked row in a tier to its default. keyFn maps a row entry to + b.onclick=()=>{onToggle(at);b.classList.toggle('on',!!isOn(at));};return b;});} +// Apply a batch action to every editable row in a tier. keyFn maps a row entry to // its lock key, or null to skip the row entirely (syntax bg and the default fg); // resetFn does the actual clearing. Locked rows are left untouched. function clearUnlockedRows(items,keyFn,resetFn){ for(const it of items){const k=keyFn(it);if(k===null)continue;if(!LOCKED.has(k))resetFn(it);} } +function rebuildColorTables(){ + buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); +} +function refreshPaletteState(opts={}){ + renderPalette();rebuildColorTables(); + if(opts.pkgPreview)buildPkgPreview(); + if(opts.code!==false)renderCode(); + if(opts.ground!==false)applyGround(); + if(opts.covered)repaintCovered(); +} +function syntaxLockKeys(){return CATS.map(c=>c[0]);} +function uiLockKeys(){return UI_FACES.map(f=>'ui:'+f[0]);} +function pkgLockKeys(){const app=curApp();return APPS[app].faces.map(f=>'pkg:'+app+':'+f[0]);} +function tierLockKeys(tier){return tier==='syntax'?syntaxLockKeys():tier==='ui'?uiLockKeys():pkgLockKeys();} +function updateLockToggle(tier){ + const ids={syntax:'syntaxlocktoggle',ui:'uilocktoggle',pkg:'pkglocktoggle'},b=document.getElementById(ids[tier]);if(!b)return; + b.textContent=lockToggleLabel(tierLockKeys(tier),LOCKED); +} +function updateLockToggles(){updateLockToggle('syntax');updateLockToggle('ui');updateLockToggle('pkg');} +function toggleAllLocks(tier){ + const all=areAllLocked(tierLockKeys(tier),LOCKED); + LOCKED=toggleLockSet(tierLockKeys(tier),LOCKED); + if(tier==='syntax')buildTable();else if(tier==='ui')buildUITable();else buildPkgTable(); + updateLockToggles(); + notify((all?'unlocked ':'locked ')+(tier==='pkg'?'package':tier)+' rows',false); +} function clearUnlocked(){ - clearUnlockedRows(CATS,c=>(c[0]==='bg'||c[0]==='p')?null:c[0],c=>{MAP[c[0]]='';}); - buildTable();renderCode();notify('cleared unlocked elements to default',false); + clearUnlockedRows(CATS,c=>(c[0]==='bg'||c[0]==='p')?null:c[0],c=>{SYNTAX[c[0]]=syntaxBlank(c[0]);SYNTAX[c[0]].fg=null;syncSyntaxCache(c[0]);}); + buildTable();renderCode();notify('erased editable syntax elements',false); +} +function resetUnlocked(){ + clearUnlockedRows(CATS,c=>c[0],c=>{const k=c[0];SYNTAX[k]=JSON.parse(JSON.stringify(DEFAULT_SYNTAX[k]||syntaxBlank(k)));syncSyntaxCache(k);}); + rebuildColorTables();buildPkgPreview();renderCode();applyGround();repaintCovered(); + notify('reset editable syntax elements to captured defaults',false); } function clearUnlockedUI(){ - clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};}); - buildUITable();buildMockFrame();notify('cleared unlocked UI faces to default',false); + clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]=uiFaceBlank();}); + buildUITable();buildMockFrame();notify('erased editable UI faces',false); +} +function resetUnlockedUI(){ + clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]=JSON.parse(JSON.stringify(DEFAULT_UIMAP[f[0]]||uiFaceBlank()));}); + buildUITable();buildMockFrame();notify('reset editable UI faces to captured defaults',false); } function clearUnlockedPkg(){ const app=curApp(); - clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false,inherit:null,height:1,source:'cleared'};}); - pkgChanged();notify('cleared unlocked '+app+' faces to default',false); + clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]=normalizePkgFace({source:'cleared'},'cleared');}); + pkgChanged();notify('erased editable '+app+' faces',false); } function buildTable(){ const tb=document.getElementById('legbody');tb.innerHTML=''; for(const [kind,label,ex] of CATS){ const tr=document.createElement('tr');tr.dataset.kind=kind; - const cur=MAP[kind]||'';const list=ddList(cur); + const sf=syntaxFace(kind),cur=sf.fg||'',list=ddList(cur); const exTd=document.createElement('td');exTd.className='ex';exTd.textContent=ex; const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; - function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';} - function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);} - const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'||kind==='p'){applyGround();buildTable();buildPkgTable();buildPkgPreview();}repaintCovered();}); + function rowFg(){return kind==='bg'?MAP['p']:effFg(syntaxFace(kind).fg);} + function rowBg(){return syntaxFace(kind).bg||MAP['bg'];} + function styleEx(){const s=syntaxFace(kind);exTd.style.color=rowFg();exTd.style.background=rowBg();exTd.style.fontWeight=s.bold?'bold':'normal';exTd.style.fontStyle=s.italic?'italic':'normal';exTd.style.textDecoration=(s.underline?'underline ':'')+(s.strike?'line-through':'')||'none';exTd.style.boxShadow=boxCss(s.box,rowBg());} + function styleCr(){const r=contrast(rowFg(),rowBg());crTd.innerHTML=crHtml(r);} + const dd=mkColorDropdown(list,cur,(hex)=>{const s=syntaxFace(kind);s.fg=hex||null;syncSyntaxCache(kind);styleEx();styleCr();renderCode();if(kind==='bg'||kind==='p'){applyGround();buildTable();buildPkgTable();buildPkgPreview();}repaintCovered();},{compact:true}); + const bgd=mkColorDropdown(ddList(sf.bg||''),sf.bg||'',hex=>{const s=syntaxFace(kind);s.bg=hex||null;styleEx();styleCr();renderCode();repaintCovered();},{compact:true}); styleEx();styleCr(); - const lkTd=mkLockCell(kind,[dd]); - // style buttons const stTd=document.createElement('td'); - if(kind!=='bg'){const defs=[['B','a','bold'],['I','a','italic']]; - const btns={}; - defs.forEach(([id,ch,mode])=>{const b=document.createElement('button');b.className='sbtn';b.style.fontWeight=mode==='bold'?'bold':'normal';b.style.fontStyle=mode==='italic'?'italic':'normal';b.textContent=ch; - b.onclick=()=>{if(mode==='bold'){BOLD[kind]=!BOLD[kind];}else{ITALIC[kind]=!ITALIC[kind];}refresh();renderCode();styleEx();}; - btns[mode]=b;stTd.appendChild(b);}); - function refresh(){btns.bold.classList.toggle('on',!!BOLD[kind]);btns.italic.classList.toggle('on',!!ITALIC[kind]);} - refresh();} + const stBtns=mkStyleButtons(at=>syntaxFace(kind)[at],at=>{const s=syntaxFace(kind);s[at]=!s[at];styleEx();renderCode();}); + stBtns.forEach(b=>stTd.appendChild(b)); const c0=document.createElement('td');c0.appendChild(dd); + const cB=document.createElement('td');cB.appendChild(bgd); + const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>syntaxFace(kind).box,b=>{syntaxFace(kind).box=b;styleEx();renderCode();},{compact:true});cX.appendChild(boxCtl); + const lkTd=mkLockCell(kind,[dd,bgd,...stBtns,boxCtl]); const c2=document.createElement('td');c2.className='cat';c2.textContent=label;c2.style.cursor='pointer';c2.title='flash this category in the code';c2.onclick=()=>flashTokens(kind); - tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd); + tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(cB);tr.appendChild(stTd);tr.appendChild(cX);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);} + updateLockToggle('syntax'); } -let selectedIdx=null; -// When a named palette color is deleted, remember its hex keyed by name so that -// recreating a color with the same name can re-bind the assignments still pointing -// at the old (now "(gone)") hex. Consumed once per name; cleared on import. -let lastGone={}; -// Re-point every assignment — syntax map, UI faces, package faces — from one hex -// to another. Used when a palette color's value is edited and when a deleted name -// is recreated. -function repointHex(oldHex,newHex){ - if(oldHex===newHex)return; - for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} - for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} - for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} -} -// On adding a color, if its name matches a recently-deleted one, re-bind the -// stranded assignments to the new hex. Returns true when a heal context existed. -function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;} -// Pairwise OKLab ΔE over the palette. Returns the sub-threshold pairs (sorted -// closest-first) and each color's nearest-neighbor distance for its chip title. -// Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it. -function renderPaletteWarnings(warnings,overflow){ - const w=document.getElementById('palwarn');if(!w)return; - if(!warnings.length){w.style.display='none';w.innerHTML='';return;} - let html='<div class="pwh">too-similar colors</div>'; - html+=warnings.map(p=>`<div class="pwl">${esc(p.aName+' / '+p.bName)} — \u0394E ${p.dE.toFixed(3)}, hard to distinguish</div>`).join(''); - if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`; - w.innerHTML=html;w.style.display='block'; -} -// One palette chip for PALETTE[i], with its remove / rename / select handlers. -// Families sort deterministically, so the old move-arrow / drag reordering is gone. -function paletteChip(i,nearest){ - const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i]; - const locked=(hex===MAP['bg']||hex===MAP['p']); - const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; - d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); - const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; - d.innerHTML=`${rm}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; - d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; - d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);}; - return d; -} -// Render the palette as hue families: the pinned ground strip, then hue-sorted -// family strips, each dark to light. Grouping is derived from the hex by -// familiesFromPalette every render, so renaming a color never moves it. The flat -// PALETTE stays the editable truth; chips keep their per-chip controls. -function renderPalette(){ - const p=document.getElementById('pals');p.innerHTML=''; - const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); - const used=new Set(); - const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;}; - const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; - const gs=strip(' ground');gs.dataset.family='ground'; - ground.forEach(g=>{ - const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex); - if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));} - else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex; - sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);} - }); - // The too-similar warning stays on the full flat palette: a generated ramp's - // steps are a stepL apart (well above the warning's ΔE threshold), so they never - // trigger it, and any pair that does is a genuine near-duplicate worth flagging. - sortFamilies(families).forEach(f=>{ - const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base; - f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); - if(!f.neutral)s.appendChild(familyCountControl(f)); - }); - renderPaletteWarnings(warnings,overflow); - buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); -} -// The per-family count control under a chromatic strip. Its value is the family's -// current per-side reach; setting N regenerates the family as base ±N. -function familyCountControl(f){ - const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset))); - const d=document.createElement('div');d.className='fcount'; - d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">± <input type="number" min="0" max="4" value="${per}"></span>`; - d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); - return d; -} -// Regenerate a family as a symmetric base ±N ramp, replacing its current members. -// References to a surviving position (matched by signed lightness rank) follow the -// new hex; references to a position removed by lowering N leave their old hex, -// which is no longer in the palette and so renders as "(gone)". -// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving -// references and leaving removed ones on their now-gone hex. Returns the removed -// count, or null on a bad base. Shared by the count control and the base edit. -function regenFamilyInPlace(oldHexes,baseHex,baseName,n){ - const r=regenFamily(baseHex,n,{}); - if(r.error){notify('cannot regenerate from '+baseHex,true);return null;} - const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members); - const oldSet=new Set(oldHexes.map(h=>h.toLowerCase())); - let at=PALETTE.length; - for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;} - for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); - const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]); - PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); - for(const [o,nw] of plan.map)repointHex(o,nw); - return plan.removed.length; -} -function setFamilyCount(baseHex,n){ - const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); - const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); - if(!fam)return; - const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; - const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n); - if(removed===null)return; - selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); - notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); -} +PALETTE_ACTIONS_J function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);} function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();} function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);} function updateColor(){ if(selectedIdx===null){notify('click a palette color to select it first',true);return;} - const i=selectedIdx,oldHex=PALETTE[i][0]; + const i=selectedIdx,oldHex=PALETTE[i][0],oldRole=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']}); const newHex=curHex(); const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1]; if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;} - // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count. - const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families; - const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase()); - const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0; - PALETTE[i]=[newHex,newName]; - repointHex(oldHex,newHex); - if(fam&&count>0){ - const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); - regenFamilyInPlace(oldHexes,newHex,newName,count); - closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return; + const isGroundEdit=oldRole==='bg'||oldRole==='fg'; + // If the edited color is a column base with a ramp, recolor the whole column: regenerate from the new base at the same count. + const columns=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns; + const column=isGroundEdit?null:columns.find(f=>f.base.toLowerCase()===oldHex.toLowerCase()); + const count=column?Math.max(0,...rankByLightness(column.members.map(m=>m.hex),column.base).map(m=>Math.abs(m.offset))):0; + const columnId=isGroundEdit?'ground':(PALETTE[i][2]||columnStem(PALETTE[i][1])); + PALETTE[i]=[newHex,newName,columnId]; + const duplicateOldHex=PALETTE.some((p,j)=>j!==i&&p[0].toLowerCase()===oldHex.toLowerCase()); + if(isGroundEdit)repointHex(oldHex,newHex); + else if(!duplicateOldHex&&oldHex!==MAP['bg']&&oldHex!==MAP['p'])repointHex(oldHex,newHex); + if(column&&count>0){ + const oldHexes=column.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); + regenColumnInPlace(oldHexes,newHex,newName,count,column.column||columnId); + closePicker();selectedIdx=null;refreshPaletteState();notify('recolored "'+newName+'" column from the new base',false);return; } - closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); + closePicker();refreshPaletteState();notify('updated "'+newName+'"',false); } -function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} -let pkH=0,pkS=0,pkV=0.5,pickerOn=false; +const DEFAULT_PICKER_HEX='#67809c'; +let [pkH,pkS,pkV]=rgb2hsv(...hex2rgb(DEFAULT_PICKER_HEX)),pickerOn=false; +function curHex(){return normHex(document.getElementById('newhexstr').value)||DEFAULT_PICKER_HEX;} let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) let pkModel='hsv'; // color model for editing: hsv / oklch (orthogonal to pkMode) const OKLCH_CMAX=0.4; // chroma axis range for the C×L plane (and the C dial); past sRGB at most hues, so the gamut grey shows the reachable region @@ -324,28 +279,29 @@ function paintPicker(){const sv=document.getElementById('sv');if(!sv)return; function pkReadout(h){const e=document.getElementById('pkhex');if(e)e.textContent=h;const c=document.getElementById('pkcon');if(c){const r=contrast(h,MAP['bg']);c.textContent=r.toFixed(1)+' '+rating(r);c.style.color=ratingColor(r);} const o=document.getElementById('pkoklch');if(o){const lch=oklab2oklch(srgb2oklab(h));o.textContent='OKLCH '+lch.L.toFixed(3)+' '+lch.C.toFixed(3)+' '+Math.round(lch.H)+'\u00b0';} const a=document.getElementById('pkapca');if(a){const lc=apca(h,MAP['bg']);a.textContent='APCA Lc '+lc.toFixed(0);a.title='APCA Lc '+lc.toFixed(1)+' (APCA-W3 0.1.9), text on the ground color. Positive = dark text on a light background, negative = light text on a dark background.';}} -function syncHex(){const v=normHex(document.getElementById('newhexstr').value);if(!v)return;document.getElementById('swatch').style.background=v;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(v));if(pickerOn)paintPicker();pkReadout(v);} -function setHex(h){h=normHex(h)||h;document.getElementById('newhexstr').value=h;document.getElementById('swatch').style.background=h;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(h));if(pickerOn)paintPicker();pkReadout(h);} -function pkSet(){const hex=rgb2hex(...hsv2rgb(pkH,pkS,pkV));document.getElementById('newhexstr').value=hex;document.getElementById('swatch').style.background=hex;paintPicker();pkReadout(hex);if(pkModel==='oklch')oklchInputsFromHex(hex);} +function previewPickerHex(hex){if(pickerOn&&selectedIdx!==null)previewSelectedChip(hex);} +function syncHex(){const v=normHex(document.getElementById('newhexstr').value);if(!v)return;document.getElementById('swatch').style.background=v;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(v));if(pickerOn)paintPicker();pkReadout(v);previewPickerHex(v);} +function setHex(h){h=normHex(h)||h;document.getElementById('newhexstr').value=h;document.getElementById('swatch').style.background=h;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(h));if(pickerOn)paintPicker();pkReadout(h);previewPickerHex(h);} +function pkSet(){const hex=rgb2hex(...hsv2rgb(pkH,pkS,pkV));document.getElementById('newhexstr').value=hex;document.getElementById('swatch').style.background=hex;paintPicker();pkReadout(hex);previewPickerHex(hex);if(pkModel==='oklch')oklchInputsFromHex(hex);} // --- OKLCH editing model (Phase 4a): L/C/H dials orthogonal to the HSV square --- function setOklchInputs(L,C,H){ const put=(id,v)=>{const e=document.getElementById(id);if(e)e.value=v;}; put('okL',L.toFixed(3));put('okLn',L.toFixed(3));put('okC',C.toFixed(3));put('okCn',C.toFixed(3)); const h=String(Math.round(H));put('okH',h);put('okHn',h);} -function oklchInputsFromHex(hex){const lch=oklab2oklch(srgb2oklab(normHex(hex)||'#888888'));setOklchInputs(lch.L,lch.C,lch.H);} +function oklchInputsFromHex(hex){const lch=oklab2oklch(srgb2oklab(normHex(hex)||DEFAULT_PICKER_HEX));setOklchInputs(lch.L,lch.C,lch.H);} function readOklch(){return [parseFloat(document.getElementById('okL').value)||0,parseFloat(document.getElementById('okC').value)||0,parseFloat(document.getElementById('okH').value)||0];} function pkClampStatus(on){const s=document.getElementById('pkclamp');if(!s)return;s.classList.toggle('show',on);s.textContent=on?'chroma clamped to sRGB':'';} function pkOklchSet(){const [L,C,H]=readOklch();const {hex,clamped}=oklch2hex(L,C,H); document.getElementById('newhexstr').value=hex;document.getElementById('swatch').style.background=hex; - [pkH,pkS,pkV]=rgb2hsv(...hex2rgb(hex));paintPicker();pkReadout(hex); + [pkH,pkS,pkV]=rgb2hsv(...hex2rgb(hex));paintPicker();pkReadout(hex);previewPickerHex(hex); if(clamped)oklchInputsFromHex(hex); // snap the dials to the reachable color pkClampStatus(clamped);} function setPkModel(m){pkModel=m;document.querySelectorAll('.pmodel button').forEach(x=>x.classList.toggle('on',x.dataset.pm===m)); const oc=document.getElementById('oklchctl');if(oc)oc.classList.toggle('show',m==='oklch'); if(m==='oklch')oklchInputsFromHex(curHex());else pkClampStatus(false);} function buildPkChips(){const c=document.getElementById('pkchips');if(!c)return;c.innerHTML='';const T=pkThresh();PALETTE.forEach(([hex,name])=>{const s=document.createElement('div');s.className='pc';s.style.background=hex;s.title=name+' '+hex;const ok=!T||contrast(hex,MAP['bg'])>=T;if(!ok){s.style.opacity='0.22';s.title+=' (below '+pkMode.toUpperCase()+')';}s.onclick=()=>{if(ok)setHex(hex);};c.appendChild(s);});} -function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';setPkModel(pkModel);paintPicker();pkReadout(curHex());setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} -function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getElementById('picker');if(p)p.style.display='none';document.removeEventListener('pointerdown',pkOutside);} +function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';setPkModel(pkModel);paintPicker();pkReadout(curHex());previewPickerHex(curHex());setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} +function closePicker(){if(!pickerOn)return;restoreSelectedChip();pickerOn=false;const p=document.getElementById('picker');if(p)p.style.display='none';document.removeEventListener('pointerdown',pkOutside);} function pkOutside(e){if(!e.target.closest('#picker')&&!e.target.closest('#swatch'))closePicker();} function pkDrag(el,fn){el.addEventListener('pointerdown',e=>{e.preventDefault();fn(e);const mv=ev=>fn(ev),up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);};document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);});} function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;sw.style.background=curHex();sw.onclick=()=>pickerOn?closePicker():openPicker(); @@ -363,66 +319,67 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim(); 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]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); - renderPalette();buildTable();buildUITable(); - if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();} - notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);} + PALETTE.push([h,name,columnIdOf([h,name])]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); + refreshPaletteState({code:healed,ground:healed,pkgPreview:healed}); + notify(healed?('added "'+name+'" and reconnected its face references'):('added "'+name+'"'),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;} +function exportObj(){normalizePalette();const o={name:themeName(),palette:PALETTE,syntax:SYNTAX,ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();} function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}} -function updateTitle(){const n=document.getElementById('themename').value.trim();document.getElementById('pagetitle').textContent=(n||'Untitled')+': theme';const sb=document.getElementById('savebtn');if(sb){sb.style.display=n||fileHandle?'':'none';sb.title=fileHandle?'overwrite the imported/saved file':'choose where to save';}} -let fileHandle=null; +function updateTitle(){const n=document.getElementById('themename').value.trim();document.getElementById('pagetitle').textContent=(n||'Untitled')+': theme';} function exportTheme(){const blob=new Blob([JSON.stringify(exportObj(),null,1)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fileSlug()+'.json';a.click();} -async function saveTheme(){const data=JSON.stringify(exportObj(),null,1); - if(!window.showSaveFilePicker){exportTheme();notify('saved via download (browser has no Save-File support)',false);return;} - try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); - const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle(); - }catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}} -function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); - BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true); +function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette.map(normalizePaletteEntry); + if(!d.syntax)throw new Error('theme JSON is missing syntax; convert older files first'); + SYNTAX={};CATS.forEach(c=>{const k=c[0];SYNTAX[k]=Object.assign(syntaxBlank(k),d.syntax[k]||{});});syncAllSyntaxCache(); LOCKED=new Set(d.locks||[]); if(d.ui)Object.assign(UIMAP,d.ui); PKGMAP=seedPkgmap();if(d.packages)mergePackagesInto(PKGMAP,d.packages); - renderPalette();buildTable();buildUITable();buildPkgTable();buildPkgPreview();renderCode();applyGround();updateTitle();} -// File-input fallback (no File System Access API): no writable handle, so save still prompts. + refreshPaletteState({pkgPreview:true});updateTitle();} function importFile(ev){const f=ev.target.files[0];if(!f)return;const r=new FileReader(); - r.onload=()=>{try{applyImported(r.result);fileHandle=null;updateTitle();}catch(e){alert('bad theme file: '+e.message);}}; + r.onload=()=>{try{applyImported(r.result);updateTitle();}catch(e){alert('bad theme file: '+e.message);}}; r.readAsText(f);ev.target.value='';} -// Preferred import: keep the file handle so a later save overwrites the same file. async function importTheme(){ if(!window.showOpenFilePicker){const fi=document.getElementById('fileinput');if(fi)fi.click();return;} try{const [h]=await window.showOpenFilePicker({types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); - const file=await h.getFile();applyImported(await file.text());fileHandle=h;updateTitle(); - notify('imported "'+(themeName()||file.name)+'" — save now overwrites it',false); + const file=await h.getFile();applyImported(await file.text());updateTitle(); + notify('imported "'+(themeName()||file.name)+'"',false); }catch(e){if(e&&e.name!=='AbortError')notify('import failed: '+e.message,true);}} // The blanket covers only the code panes and syntax example cells. UI-face // preview cells also carry .ex, but a face with its own bg must keep it, so // those rows repaint through paintUI (which also re-rates the contrast cell // against the new ground for faces without their own bg). -function applyGround(){document.querySelectorAll('pre').forEach(p=>p.style.background=MAP['bg']);document.querySelectorAll('#legbody .ex').forEach(e=>e.style.background=MAP['bg']);UI_FACES.forEach(([f])=>{if(document.getElementById('uiprev-'+f))paintUI(f);});} +function applyGround(){document.querySelectorAll('pre').forEach(p=>p.style.background=MAP['bg']);UI_FACES.forEach(([f])=>{if(document.getElementById('uiprev-'+f))paintUI(f);});} function uf(f){return UIMAP[f]||{};} function udeco(o){return `font-weight:${o.bold?'bold':'normal'};font-style:${o.italic?'italic':'normal'};text-decoration:${(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none'}`;} // A face's :box, rendered as an inset box-shadow (no layout shift). Returns the // box-shadow VALUE (or '' for no box). 'line' is a flat border in the box color // (or the face's own color when unset); 'released'/'pressed' are the 3D button -// styles Emacs draws, derived from the background so they read on any color. +// styles Emacs draws, derived from explicit box color when set, otherwise the +// background so they read on any color. function boxCss(b,bg){if(!b||!b.style)return '';const w=b.width||1; if(b.style==='released'||b.style==='pressed'){ - // Emacs derives the 3D edges from the face's background (reliefColors, - // ported from xterm.c); the translucent pair is only the no-bg fallback. - const r=bg?reliefColors(bg):{hl:null,sh:null}; + // Emacs derives the 3D edges from a base color (reliefColors, ported from + // xterm.c); the translucent pair is only the no-color fallback. + const r=(b.color||bg)?reliefColors(b.color||bg):{hl:null,sh:null}; const hl=r.hl||'#ffffff33',sh=r.sh||'#00000066'; const [a,z]=b.style==='released'?[hl,sh]:[sh,hl]; return `inset ${w}px ${w}px 0 ${a},inset -${w}px -${w}px 0 ${z}`;} return `inset 0 0 0 ${w}px ${b.color||'currentColor'}`;} -// The per-row box control: none / line / raised / pressed. get()/set() read and -// write the face's box object (null = no box). -function mkBoxSelect(get,set){const s=document.createElement('select');s.className='chip';s.style.cssText='width:84px;font:10pt monospace'; +function syntaxStyle(k){const s=syntaxFace(k),fg=(k==='bg'?MAP['p']:effFg(s.fg)),bg=s.bg||null,dec=(s.underline?'underline ':'')+(s.strike?'line-through':''), + bx=boxCss(s.box,bg||MAP['bg']); + return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${s.bold?'bold':'normal'};font-style:${s.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} +// The per-row box control: none / line / raised / pressed plus optional line +// color. get()/set() read and write the face's box object (null = no box). +function mkBoxControl(get,set,opts={}){const wrap=document.createElement('div');wrap.className='boxctl'; + const s=document.createElement('select');s.className='chip';s.style.cssText='width:84px;font:10pt monospace'; [['','no box'],['line','line'],['released','raised'],['pressed','pressed']].forEach(([v,l])=>{const o=document.createElement('option');o.value=v;o.textContent=l;s.appendChild(o);}); - const cur=get();s.value=cur&&cur.style?cur.style:''; - s.onchange=()=>set(s.value?{style:s.value,width:1,color:null}:null);return s;} + const dd=mkColorDropdown(ddList((get()&&get().color)||''),(get()&&get().color)||'',h=>{const cur=get();if(!cur)return;set(Object.assign({},cur,{color:h||null}));},{compact:!!opts.compact}); + function paint(){const cur=get();s.value=cur&&cur.style?cur.style:'';dd.setValue(cur&&cur.color?cur.color:''); + const off=!cur||!cur.style||wrap.dataset.locked==='1';dd.dataset.locked=off?'1':'';dd.classList.toggle('locked',off);if(dd.syncLocked)dd.syncLocked();} + s.onchange=()=>{const cur=get();set(s.value?{style:s.value,width:cur&&cur.width||1,color:cur&&cur.color||null}:null);paint();}; + wrap.syncLocked=()=>{const locked=wrap.dataset.locked==='1';s.disabled=locked;paint();}; + wrap.append(s,dd);paint();return wrap;} function flashRow(tr){if(!tr)return;tr.scrollIntoView({block:'center',behavior:'smooth'});tr.classList.remove('flash');void tr.offsetWidth;tr.classList.add('flash');} function flashEl(el){if(!el)return;el.scrollIntoView({block:'nearest',inline:'nearest',behavior:'smooth'});el.classList.remove('flashtok');void el.offsetWidth;el.classList.add('flashtok');} // Flash every matching element but scroll only the first into view, so a face @@ -434,7 +391,10 @@ function flashUi(f){flashRow(document.querySelector(`#uibody tr[data-face="${f}" function flashUiPreview(f){const sp=document.querySelectorAll(`#mockframe [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const cell=document.getElementById('uiprev-'+f);if(cell)flashEl(cell);} function flashPkg(f){flashRow(document.querySelector(`#pkgbody tr[data-face="${f}"]`));} function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const row=document.querySelector(`#pkgbody tr[data-face="${f}"]`);if(row)flashEl(row.querySelector('.cat'));} -function mockSpan(k,t){return `<span data-k="${k}" style="color:${effFg(MAP[k])};font-weight:${BOLD[k]?'bold':'normal'};font-style:${ITALIC[k]?'italic':'normal'}">${esc(t)}</span>`;} +function mockSpan(k,t){return `<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`;} +function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv,dec=(o.underline?'underline ':'')+(o.strike?'line-through':''), + bx=boxCss(o.box,bg||MAP['bg']); + return `color:${fg};${bg&&!opts.noBg?'background:'+bg+';':''}font-weight:${o.bold?'bold':'normal'};font-style:${o.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} function buildMockFrame(){ const fr=document.getElementById('mockframe');if(!fr)return; @@ -449,7 +409,7 @@ function buildMockFrame(){ {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','count'],['p',' '],['num','42'],['punc',')']],region:1}, {t:[['p',' '],['punc','('],['kw','if'],['p',' '],['punc','('],['op','>'],['p',' '],['var','count'],['p',' '],['num','0'],['punc',')']],match:1}, {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','total'],['p',' '],['punc','('],['op','+'],['p',' '],['var','total'],['p',' '],['var','count'],['punc','))']],hl:1}, - {t:[['p',' '],['punc','('],['fnc','process'],['p',' '],['var','items'],['punc',')']],cont:1}, + {t:[['p',' '],['punc','('],['fnc','process'],['p',' '],['var','items'],['punc',')']],cont:1,high:1}, {t:[['p',' '],['punc','('],['fnc','cl-incf'],['p',' '],['var','count'],['punc',')']],lazy:1}, {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','done'],['p',' '],['con','t'],['punc',')']],paren:1}, {t:[['p',' '],['punc','('],['fnc','oops'],['p',' '],['var','nested'],['punc','))']],mismatch:1} @@ -461,14 +421,14 @@ function buildMockFrame(){ const inner=face.fg ? `<span style="color:${face.fg}">${tokens.map(([,t])=>esc(t)).join('')}</span>` : tokens.map(([k,t])=>mockSpan(k,t)).join(''); - return `<span data-face="${dface}" style="background:${face.bg||'transparent'};${udeco(face)}">${inner}</span>`; + return `<span data-face="${dface}" style="${uiCss(face,face.fg||'inherit',face.bg||'transparent')}">${inner}</span>`; }; - // Emacs box cursor: it sits on the character at point, drawn in the frame - // background over the cursor color (the cursor face's foreground is ignored). + // Emacs box cursor: it sits on the character at point, drawn in the configured + // cursor background, with the glyph in the configured cursor foreground. // Falls back to a trailing block only if the line has no glyph (point at EOL). const withCursor=(tokens)=>{ let out='',placed=false; - const cell=ch=>`<span data-face="cursor" style="background:${cur.bg||fg};color:${bg}">${esc(ch)}</span>`; + const cell=ch=>`<span data-face="cursor" style="${uiCss(cur,cur.fg||bg,cur.bg||fg)}">${esc(ch)}</span>`; for(const [k,t] of tokens){ const m=placed?-1:t.search(/\S/); if(m>=0){ @@ -485,25 +445,25 @@ function buildMockFrame(){ lines.forEach((L,i)=>{ const isc=L.cur; const nFg=isc?(lnc.fg||fg):(ln.fg||fg), nBg=isc?(lnc.bg||'transparent'):(ln.bg||'transparent'); - const rowBg=isc?(hl.bg||'transparent'):'transparent'; + const rowFace=isc?hl:null,rowStyle=rowFace?uiCss(rowFace,rowFace.fg||'inherit',rowFace.bg||'transparent'):'background:transparent'; let cd; if(isc)cd=withCursor(L.t); else if(L.region)cd=overlay(L.t,reg,'region'); - else if(L.hl)cd=overlay(L.t,hil,'highlight'); + else if(L.high)cd=overlay(L.t,hil,'highlight'); else if(L.match)cd=overlay(L.t,isr,'isearch'); else if(L.lazy)cd=overlay(L.t,laz,'lazy-highlight'); - else if(L.paren)cd=L.t.map(([k,t],j)=>j===L.t.length-1?`<span data-face="show-paren-match" style="background:${par.bg||'transparent'};color:${par.fg||MAP[k]||fg};${udeco(par)}">${esc(t)}</span>`:mockSpan(k,t)).join(''); - else if(L.mismatch)cd=L.t.map(([k,t],j)=>{if(j!==L.t.length-1)return mockSpan(k,t);const head=t.slice(0,-1),bad=t.slice(-1);return (head?mockSpan(k,head):'')+`<span data-face="show-paren-mismatch" style="background:${parx.bg||'transparent'};color:${parx.fg||MAP[k]||fg};${udeco(parx)}">${esc(bad)}</span>`;}).join(''); + else if(L.hl)cd=overlay(L.t,hl,'hl-line'); + else if(L.paren)cd=L.t.map(([k,t],j)=>j===L.t.length-1?`<span data-face="show-paren-match" style="${uiCss(par,par.fg||syntaxFace(k).fg||fg,par.bg||'transparent')}">${esc(t)}</span>`:mockSpan(k,t)).join(''); + else if(L.mismatch)cd=L.t.map(([k,t],j)=>{if(j!==L.t.length-1)return mockSpan(k,t);const head=t.slice(0,-1),bad=t.slice(-1);return (head?mockSpan(k,head):'')+`<span data-face="show-paren-mismatch" style="${uiCss(parx,parx.fg||syntaxFace(k).fg||fg,parx.bg||'transparent')}">${esc(bad)}</span>`;}).join(''); else cd=L.t.map(([k,t])=>mockSpan(k,t)).join(''); const nFace=isc?'line-number-current-line':'line-number'; - buf+=`<div class="ln" style="background:${rowBg}"><span class="fr" data-face="fringe" style="background:${frng.bg||bg};color:${frng.fg||fg};text-align:center;font-size:10px;overflow:hidden" title="fringe">${L.cont?'↪':''}</span><span class="num" data-face="${nFace}" style="color:${nFg};background:${nBg};${udeco(isc?lnc:ln)}">${i+1}</span><span class="cd">${cd||' '}</span></div>`; + buf+=`<div class="ln" ${rowFace?'data-face="hl-line" ':''}style="${rowStyle}"><span class="fr" data-face="fringe" style="${uiCss(frng,frng.fg||fg,frng.bg||bg)};text-align:center;font-size:10px;overflow:hidden" title="fringe">${L.cont?'↪':''}</span><span class="num" data-face="${nFace}" style="${uiCss(isc?lnc:ln,nFg,nBg)}">${i+1}</span><span class="cd">${cd||' '}</span></div>`; }); let html=`<div class="mbuf" style="display:flex;background:${bg}"><div style="flex:1;min-width:0">${buf}</div><div data-face="vertical-border" title="vertical-border" style="width:3px;flex:0 0 auto;background:${vb.fg||vb.bg||'#2f343a'}"></div></div>`; - const mlbx=boxCss(ml.box,ml.bg||bg),mlibx=boxCss(mli.box,mli.bg||bg); - html+=`<div class="bar" data-face="mode-line" style="background:${ml.bg||fg};color:${ml.fg||bg};${udeco(ml)}${mlbx?';box-shadow:'+mlbx:''}"> init.el (Emacs Lisp) L5 git:main </div>`; - html+=`<div class="bar" data-face="mode-line-inactive" style="background:${mli.bg||bg};color:${mli.fg||fg};${udeco(mli)}${mlibx?';box-shadow:'+mlibx:''}"> *Messages* (Fundamental) </div>`; - html+=`<div class="echo" style="color:${fg}"><span data-face="minibuffer-prompt" style="color:${mb.fg||fg};${udeco(mb)}">I-search:</span> count <span data-face="isearch-fail" style="color:${isf.fg||fg};background:${isf.bg||'transparent'};${udeco(isf)}">zzz [no match]</span></div>`; - html+=`<div class="echo"><span data-face="link" style="color:${lnk.fg||fg};${udeco(lnk)}">https://gnu.org</span> <span data-face="error" style="color:${err.fg||fg};${udeco(err)}">error</span> <span data-face="warning" style="color:${wrn.fg||fg};${udeco(wrn)}">warning</span> <span data-face="success" style="color:${suc.fg||fg};${udeco(suc)}">ok</span></div>`; + html+=`<div class="bar" data-face="mode-line" style="${uiCss(ml,ml.fg||bg,ml.bg||fg)}"> init.el (Emacs Lisp) L5 git:main </div>`; + html+=`<div class="bar" data-face="mode-line-inactive" style="${uiCss(mli,mli.fg||fg,mli.bg||bg)}"> *Messages* (Fundamental) </div>`; + html+=`<div class="echo" style="color:${fg}"><span data-face="minibuffer-prompt" style="${uiCss(mb,mb.fg||fg,mb.bg||null)}">I-search:</span> count <span data-face="isearch-fail" style="${uiCss(isf,isf.fg||fg,isf.bg||'transparent')}">zzz [no match]</span></div>`; + html+=`<div class="echo"><span data-face="link" style="${uiCss(lnk,lnk.fg||fg,lnk.bg||null)}">https://gnu.org</span> <span data-face="error" style="${uiCss(err,err.fg||fg,err.bg||null)}">error</span> <span data-face="warning" style="${uiCss(wrn,wrn.fg||fg,wrn.bg||null)}">warning</span> <span data-face="success" style="${uiCss(suc,suc.fg||fg,suc.bg||null)}">ok</span></div>`; fr.innerHTML=html;fr.style.background=bg;fr.style.color=fg; fr.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u){flashUi(u.dataset.face);return;}const k=e.target.closest('[data-k]');if(k)flashAssign(k.dataset.k);}; } @@ -511,9 +471,10 @@ function buildMockFrame(){ // native <select> rendered swatch colors unreliably on Linux Chrome, so it is // gone. '' (the default entry) maps back to null in the stored model. function uiSelect(face,attr){const cur=UIMAP[face][attr]||''; - return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();});} + return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();},{compact:true});} const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow']; -function seedFace(d){return {fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};} +function uiFaceBlank(){return {fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};} +function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,inherit:d.inherit,height:d.height,box:d.box},'default');} function curApp(){const s=document.getElementById('appsel');return s&&s.value?s.value:Object.keys(APPS)[0];} function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);} function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);} @@ -527,8 +488,8 @@ function buildPkgTable(){ if(flt&&!(face.toLowerCase().includes(flt)||label.toLowerCase().includes(flt)))continue; const f=PKGMAP[app][face],tr=document.createElement('tr');tr.dataset.face=face; const c0=document.createElement('td');c0.className='cat';c0.textContent=label;c0.title=face;c0.style.cursor='pointer';c0.onclick=()=>flashPkgPreview(face); - const fgd=mkColorDropdown(ddList(f.fg||''),f.fg||'',h=>{f.fg=h||null;f.source='user';pkgChanged();}), - bgd=mkColorDropdown(ddList(f.bg||''),f.bg||'',h=>{f.bg=h||null;f.source='user';pkgChanged();}); + const fgd=mkColorDropdown(ddList(f.fg||''),f.fg||'',h=>{f.fg=h||null;f.source='user';pkgChanged();},{compact:true}), + bgd=mkColorDropdown(ddList(f.bg||''),f.bg||'',h=>{f.bg=h||null;f.source='user';pkgChanged();},{compact:true}); const cf=document.createElement('td');cf.appendChild(fgd); const cb=document.createElement('td');cb.appendChild(bgd); const cw=document.createElement('td'); @@ -537,12 +498,13 @@ function buildPkgTable(){ const ci=document.createElement('td');const isel=document.createElement('select');isel.className='chip';isel.style.cssText='width:150px;font:10pt monospace';inh.forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);});isel.value=f.inherit||'';isel.onchange=()=>{f.inherit=isel.value||null;f.source='user';pkgChanged();};ci.appendChild(isel); const ch=document.createElement('td');const hin=document.createElement('input');hin.type='number';hin.min='0.8';hin.max='2.5';hin.step='0.05';hin.value=f.height||1;hin.className='hstep';hin.onchange=()=>{f.height=parseFloat(hin.value)||1;f.source='user';pkgChanged();};ch.appendChild(hin); const cc=document.createElement('td');cc.style.fontSize='10pt';cc.style.whiteSpace='nowrap';const efg=effFg(pkgEffFg(app,face)),ebg=effBg(pkgEffBg(app,face)),r=contrast(efg,ebg);cc.innerHTML=crHtml(r); - const cx=document.createElement('td');const boxSel=mkBoxSelect(()=>f.box,b=>{f.box=b;f.source='user';pkgChanged();});cx.appendChild(boxSel); + const cx=document.createElement('td');const boxCtl=mkBoxControl(()=>f.box,b=>{f.box=b;f.source='user';pkgChanged();},{compact:true});cx.appendChild(boxCtl); const cr=document.createElement('td');const rb=document.createElement('button');rb.className='sbtn';rb.textContent='↺';rb.title='reset to default';rb.onclick=()=>{PKGMAP[app][face]=seedFace(def);pkgChanged();};cr.appendChild(rb); - const cL=mkLockCell('pkg:'+app+':'+face,[fgd,bgd,...pkBtns,isel,hin,boxSel,rb]); + const cL=mkLockCell('pkg:'+app+':'+face,[fgd,bgd,...pkBtns,isel,hin,boxCtl,rb]); tr.append(c0,cL,cf,cb,cw,cc,ci,ch,cx,cr);tb.appendChild(tr); } applyTableSort('pkgbody'); + updateLockToggle('pkg'); } function ofs(app,face){const f=PKGMAP[app][face]||{},fg=effFg(pkgEffFg(app,face)),bg=pkgEffBg(app,face);const dec=(f.underline?'underline ':'')+(f.strike?'line-through':'');const bx=boxCss(f.box,bg||MAP['bg']);return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${f.bold?'bold':'normal'};font-style:${f.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'};font-size:${(f.height||1)}em${bx?';box-shadow:'+bx:''}`;} function os(app,face,txt){return `<span data-face="${face}" style="${ofs(app,face)}">${txt}</span>`;} @@ -561,7 +523,7 @@ function renderOrgPreview(){const a='org-mode',L=[]; L.push(' '+os(a,'org-checkbox','[X]')+' done item '+os(a,'org-checkbox-statistics-done','[2/2]')); L.push(' '+os(a,'org-checkbox','[ ]')+' open item '+os(a,'org-checkbox-statistics-todo','[0/3]')+' '+os(a,'org-warning','(!)')); L.push(os(a,'org-level-2','** ')+os(a,'org-done','DONE')+os(a,'org-headline-done',' Ship the tool')); - L.push(os(a,'org-level-3','*** ')+os(a,'org-headline-todo','Heading three')); + L.push(os(a,'org-level-3','*** ')+os(a,'org-todo','TODO')+os(a,'org-headline-todo',' Heading three')); L.push(os(a,'org-level-4','**** four')+' / '+os(a,'org-level-5','***** five')+' / '+os(a,'org-level-6','****** six')+' / '+os(a,'org-level-7','******* seven')+' / '+os(a,'org-level-8','******** eight')); L.push(' Inline '+os(a,'org-code','=code=')+', '+os(a,'org-verbatim','~verbatim~')+', '+os(a,'org-inline-src-block','src_py{1+1}')+','); L.push(' a '+os(a,'org-link','[[https://gnu.org][link]]')+', a '+os(a,'org-target','<<target>>')+', a '+os(a,'org-macro','{{{macro}}}')+','); @@ -717,7 +679,7 @@ function renderGitGutterPreview(){const a='git-gutter',L=[]; return `<div style="padding:12px 16px;font:12pt/1.7 monospace;white-space:pre">${L.join('\n')}</div>`;} function renderFlycheckPreview(){const a='flycheck',L=[]; L.push(os(a,'flycheck-fringe-error','E')+os(a,'flycheck-fringe-warning','W')+os(a,'flycheck-fringe-info','I')+' x = '+os(a,'flycheck-error','undefined_name')+'('+os(a,'flycheck-warning','unused_arg')+') '+os(a,'flycheck-info','# note')); - L.push(' '+os(a,'flycheck-delimited-error','[')+os(a,'flycheck-error-delimiter','err')+os(a,'flycheck-delimited-error',']')); + L.push(' '+os(a,'flycheck-error-delimiter','[')+os(a,'flycheck-delimited-error','err')+os(a,'flycheck-error-delimiter',']')); L.push(''); L.push(os(a,'flycheck-error-list-checker-name','pyright')+' '+os(a,'flycheck-verify-select-checker','(selected checker)')); L.push(os(a,'flycheck-error-list-filename','main.py')+':'+os(a,'flycheck-error-list-line-number','12')+':'+os(a,'flycheck-error-list-column-number','4')+' '+os(a,'flycheck-error-list-error','error')+' '+os(a,'flycheck-error-list-error-message','undefined name x')+' '+os(a,'flycheck-error-list-id','[E0602]')); @@ -762,8 +724,8 @@ function renderCalibredbPreview(){const a='calibredb',L=[]; function renderErcPreview(){const a='erc',L=[]; L.push(os(a,'erc-header-line',' #emacs on Libera.Chat 18 users ')); L.push(os(a,'erc-timestamp-face','[10:24]')+' '+os(a,'erc-notice-face','*** alice has joined #emacs')); - L.push(os(a,'erc-timestamp-face','[10:25]')+' <'+os(a,'erc-my-nick-prefix-face','@')+os(a,'erc-my-nick-face','craig')+'> '+os(a,'erc-default-face','hello everyone')); - L.push(os(a,'erc-timestamp-face','[10:25]')+' <'+os(a,'erc-nick-prefix-face','+')+os(a,'erc-nick-default-face','bob')+'> '+os(a,'erc-input-face','hi craig, see ')+os(a,'erc-button','this link')+os(a,'erc-input-face',' cc ')+os(a,'erc-button-nick-default-face','@alice')); + L.push(os(a,'erc-timestamp-face','[10:25]')+' <'+os(a,'erc-my-nick-prefix-face','@')+os(a,'erc-my-nick-face','craig')+'> '+os(a,'erc-input-face','hello everyone')); + L.push(os(a,'erc-timestamp-face','[10:25]')+' <'+os(a,'erc-nick-prefix-face','+')+os(a,'erc-nick-default-face','bob')+'> '+os(a,'erc-default-face','hi craig, see ')+os(a,'erc-button','this link')+os(a,'erc-default-face',' cc ')+os(a,'erc-button-nick-default-face','@alice')); L.push(os(a,'erc-timestamp-face','[10:26]')+' '+os(a,'erc-action-face','* craig waves')+' '+os(a,'erc-keyword-face','emacs')+' '+os(a,'erc-pal-face','<friend>')+' '+os(a,'erc-fool-face','<troll>')+' '+os(a,'erc-dangerous-host-face','<bad@host>')); L.push(os(a,'erc-timestamp-face','[10:27]')+' '+os(a,'erc-direct-msg-face','(DM)')+' <'+os(a,'erc-nick-msg-face','bob')+'> psst '+os(a,'erc-current-nick-face','craig')+' '+os(a,'erc-information','-info-')); L.push(os(a,'erc-error-face','*** ERROR: connection reset')); @@ -841,9 +803,23 @@ function renderTelegaPreview(){const a='telega',L=[]; L.push(os(a,'telega-link-preview-sitename','example.com')+' '+os(a,'telega-link-preview-title','Link preview title')); L.push('Webpage '+os(a,'telega-webpage-title','Title')+' '+os(a,'telega-webpage-subtitle','Subtitle')+' '+os(a,'telega-webpage-header','Header')+' '+os(a,'telega-webpage-subheader','Subheader')+' '+os(a,'telega-webpage-outline','outline')+' '+os(a,'telega-webpage-fixed','fixed')+' '+os(a,'telega-webpage-preformatted','pre')+' '+os(a,'telega-webpage-marked','marked')+' '+os(a,'telega-webpage-strike-through','strike')+' '+os(a,'telega-webpage-chat-link','chat-link')); return `<div style="padding:12px 16px;font:12pt/1.7 monospace;white-space:pre">${L.join('\n')}</div>`;} -function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8 monospace">';for(const [face,label,def] of APPS[app].faces){const f=PKGMAP[app][face],efg=effFg(pkgEffFg(app,face)),ebg=pkgEffBg(app,face);h+=`<div data-face="${face}" style="color:${efg};${ebg?'background:'+ebg+';':''}font-weight:${f.bold?'bold':'normal'};font-style:${f.italic?'italic':'normal'};font-size:${(f.height||1)}em">${esc(label)}</div>`;}return h+'</div>';} -function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';} -function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();} +function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8 monospace">';for(const [face,label] of APPS[app].faces)h+=`<div data-face="${face}" style="${ofs(app,face)}">${esc(label)}</div>`;return h+'</div>';} +const PACKAGE_PREVIEWS={ + org:renderOrgPreview,magit:renderMagitPreview,elfeed:renderElfeedPreview,ghostel:renderGhostelPreview, + dashboard:renderDashboardPreview,mu4e:renderMu4ePreview,lsp:renderLspPreview,gitgutter:renderGitGutterPreview, + flycheck:renderFlycheckPreview,dired:renderDiredPreview,dirvish:renderDirvishPreview,calibredb:renderCalibredbPreview, + erc:renderErcPreview,orgdrill:renderOrgdrillPreview,orgnoter:renderOrgnoterPreview,signel:renderSignelPreview, + pearl:renderPearlPreview,slack:renderSlackPreview,telega:renderTelegaPreview,shr:renderShrPreview +}; +function buildPkgPreview(){ + const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return; + const renderer=PACKAGE_PREVIEWS[APPS[app].preview]; + p.innerHTML=renderer?renderer():genericPreview(app); + p.style.background=MAP['bg']; + p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);}; + const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=renderer?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)'; +} +function resetApp(){const app=curApp();for(const [face,label,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);} function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} // --- worst-case readout for the covered overlay faces (spec Phase 4) --------- // Default WCAG target for the worst-case verdict (AA). AAA is selectable. @@ -851,7 +827,7 @@ let WORST_TARGET=4.5; // The live v1 foreground set for a covered overlay face: the syntax-token colors // (every assignable category except the ground) plus the default foreground. function fgSetForFace(face){ - const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])})); + const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(syntaxFace(c[0]).fg)})); return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']}); } // The worst-case contrast cell for a covered face: the floor over its foreground @@ -883,11 +859,12 @@ function buildUITable(){ stBtns.forEach(b=>cS.appendChild(b)); const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt'; const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px'; - const cX=document.createElement('td');const boxSel=mkBoxSelect(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();});cX.appendChild(boxSel); - const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stBtns,boxSel]); + const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl); + const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stBtns,boxCtl]); tr.appendChild(c0);tr.appendChild(cL);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cC);tr.appendChild(cP);tr.appendChild(cX);tb.appendChild(tr);paintUI(face); } applyTableSort('uibody'); + updateLockToggle('ui'); } // Generic header-click sort, shared by all three tables. Reads a swatch // dropdown's value, a select value, a numeric input, or cell text (numeric when @@ -898,346 +875,10 @@ let tableSort={}; function cellVal(td){if(!td)return '';const dd=td.querySelector('.cdd');if(dd)return (dd.dataset.val||'').toLowerCase();const s=td.querySelector('select');if(s)return s.value.toLowerCase();const i=td.querySelector('input');if(i)return parseFloat(i.value)||0;const t=td.innerText.trim();const n=parseFloat(t);return (!isNaN(n)&&/^[-\d.]/.test(t))?n:t.toLowerCase();} function srtTable(tbId,col){tableSort[tbId]={col,asc:!(tableSort[tbId]&&tableSort[tbId].col===col&&tableSort[tbId].asc)};applyTableSort(tbId);} function applyTableSort(tbId){const s=tableSort[tbId];if(!s)return;const tb=document.getElementById(tbId);if(!tb)return;const dir=s.asc?1:-1;const r=[...tb.rows];r.sort((a,b)=>{const x=cellVal(a.cells[s.col]),y=cellVal(b.cells[s.col]);return ((typeof x==='number'&&typeof y==='number')?x-y:(x<y?-1:x>y?1:0))*dir;});r.forEach(x=>tb.appendChild(x));} -buildLangSel();buildAppSel();renderPalette();buildTable();buildUITable();renderCode();applyGround();updateTitle();initPicker();buildPkgTable();buildPkgPreview();syncMockHeight();syncPkgHeight(); -addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();}); -// Phase-1 self-test (open with #selftest): seed -> export -> import -> compare. -function pkgSelftest(){ - const seeded=seedPkgmap(); - seeded['org-mode']['org-level-2']={fg:'#e8bd30',bg:null,bold:false,italic:false,inherit:'org-level-1',height:1.2,source:'user'}; - const exp=packagesForExport(seeded); - const round=seedPkgmap();mergePackagesInto(round,exp); - const roundtrip=JSON.stringify(exp)===JSON.stringify(packagesForExport(round)); - let oldjson=true;try{const m=seedPkgmap();mergePackagesInto(m,undefined);oldjson=!!(m['org-mode']&&m['org-mode']['org-todo'].source==='default');}catch(e){oldjson=false;} - const l2=exp['org-mode']['org-level-2']; - const inherited=l2.inherit==='org-level-1'&&l2.source==='user'; - const height=l2.height===1.2 && !('height' in (exp['org-mode']['org-todo'])); - const sc=seedPkgmap();sc['org-mode']['org-todo']={fg:null,bg:null,bold:false,italic:false,inherit:null,height:1,source:'cleared'}; - const cleared='org-todo' in packagesForExport(sc)['org-mode']; - const su=seedPkgmap();mergePackagesInto(su,{'zzz-pkg':{'zzz-face':{fg:'#112233',source:'user'}}}); - const unknown=!!(su['zzz-pkg']&&su['zzz-pkg']['zzz-face'].fg==='#112233'); - PKGMAP['__cyc']={a:{fg:null,bg:null,bold:false,italic:false,inherit:'b',height:1,source:'user'},b:{fg:null,bg:null,bold:false,italic:false,inherit:'a',height:1,source:'user'}}; - let cyc=true;try{pkgEffFg('__cyc','a');}catch(e){cyc=false;}delete PKGMAP['__cyc']; - const verdict=(roundtrip&&oldjson&&inherited&&height&&cleared&&unknown&&cyc)?'PASS':'FAIL'; - document.title='SELFTEST '+verdict; - const d=document.createElement('div');d.id='selftest';d.textContent='SELFTEST '+verdict+' roundtrip='+roundtrip+' oldjson='+oldjson+' inherit='+inherited+' height='+height+' cleared='+cleared+' unknown='+unknown+' cycle='+cyc;document.body.appendChild(d); +function initApp(){ + buildLangSel();buildAppSel();renderPalette();rebuildColorTables();renderCode();applyGround(); + updateTitle();initPicker();buildPkgPreview();syncMockHeight();syncPkgHeight(); } -if(location.hash==='#selftest')pkgSelftest(); -// Lock-mechanism gate (open with #locktest): two behaviors the refactor must -// preserve, across all three tiers. (1) Locking a row disables its control via -// the shared mkLockCell — syntax uses a swatch div (data-locked), UI a native -// select (.disabled). (2) clear-unlocked wipes unlocked rows to default but -// leaves locked rows (syntax bare-kind, ui:, pkg: keys) untouched. -if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - LOCKED.clear();buildTable(); - {const k=CATS.map(c=>c[0]).filter(k=>k!=='bg'&&k!=='p')[0]; - const tr=document.querySelector('#legbody tr[data-kind="'+k+'"]'),dd=tr.querySelector('.cdd'),lb=tr.querySelector('.lockbtn'); - A(dd.dataset.locked!=='1','syntax-dd-starts-unlocked');lb.click(); - A(dd.dataset.locked==='1'&&dd.classList.contains('locked'),'syntax-lock-disables-dd');lb.click(); - A(dd.dataset.locked!=='1','syntax-unlock-reenables-dd');} - LOCKED.clear();buildUITable(); - {const f=UI_FACES[0][0]; - const tr=document.querySelector('#uibody tr[data-face="'+f+'"]'),dd=tr.querySelector('.cdd'),lb=tr.querySelector('.lockbtn'); - A(dd.dataset.locked!=='1','ui-dd-starts-unlocked');lb.click(); - A(dd.dataset.locked==='1'&&dd.classList.contains('locked'),'ui-lock-disables-dd');lb.click(); - A(dd.dataset.locked!=='1','ui-unlock-reenables-dd');} - {const ks=CATS.map(c=>c[0]).filter(k=>k!=='bg'&&k!=='p'),k1=ks[0],k2=ks[1]; - MAP[k1]='#111111';MAP[k2]='#222222';LOCKED.clear();LOCKED.add(k1);clearUnlocked(); - A(MAP[k1]==='#111111','syntax-clear-keeps-locked');A(MAP[k2]==='','syntax-clear-wipes-unlocked');} - {const f1=UI_FACES[0][0],f2=UI_FACES[1][0]; - UIMAP[f1].fg='#111111';UIMAP[f2].fg='#222222';LOCKED.clear();LOCKED.add('ui:'+f1);clearUnlockedUI(); - A(UIMAP[f1].fg==='#111111','ui-clear-keeps-locked');A(UIMAP[f2].fg===null,'ui-clear-wipes-unlocked');} - {const app=curApp(),pf=APPS[app].faces.map(r=>r[0]),p1=pf[0],p2=pf[1]; - PKGMAP[app][p1].fg='#111111';PKGMAP[app][p2].fg='#222222';LOCKED.clear();LOCKED.add('pkg:'+app+':'+p1);clearUnlockedPkg(); - A(PKGMAP[app][p1].fg==='#111111','pkg-clear-keeps-locked');A(PKGMAP[app][p2].fg===null,'pkg-clear-wipes-unlocked');} - document.title='LOCKTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='locktest';d.textContent='LOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Sort gate (open with #sorttest): all three tables now share srtTable/cellVal. -// Verifies the syntax table (which used to have its own srt) sorts by color -// value and by element name, that a repeat click reverses, and that the UI and -// package tables still sort. Guards the unified sort for the later stages. -if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const ddVals=tb=>[...document.querySelectorAll('#'+tb+' tr')].map(tr=>{const dd=tr.cells[2].querySelector('.cdd');return dd?(dd.dataset.val||''):'';}); - const txtVals=tb=>[...document.querySelectorAll('#'+tb+' tr')].map(tr=>tr.cells[0].innerText.trim().toLowerCase()); - const asc=a=>a.every((v,i)=>i===0||a[i-1]<=v),desc=a=>a.every((v,i)=>i===0||a[i-1]>=v); - buildTable(); - srtTable('legbody',2);A(asc(ddVals('legbody')),'legbody-color-asc'); - srtTable('legbody',2);A(desc(ddVals('legbody')),'legbody-color-desc'); - srtTable('legbody',0);A(asc(txtVals('legbody')),'legbody-elements-asc'); - buildUITable();srtTable('uibody',0);A(asc(txtVals('uibody')),'uibody-face-asc'); - buildPkgTable();srtTable('pkgbody',2);A(asc(ddVals('pkgbody')),'pkgbody-fg-asc'); - document.title='SORTTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='sorttest';d.textContent='SORTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Live-buffer rendering gate (open with #mocktest): pins the face-faithfulness -// fixes so they cannot silently regress — overlay faces keep syntax colors and -// honor their styles, the cursor sits on a glyph, line numbers honor weight, the -// fringe shows its foreground indicator, and the mode-line carries its box. -if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const Q=s=>document.querySelector('#mockframe '+s); - buildMockFrame(); - A(Q('[data-face="highlight"] [data-k]'),'highlight-keeps-token-colors'); - A(Q('[data-face="region"] [data-k]'),'region-keeps-token-colors'); - const curCell=Q('[data-face="cursor"]'); - A(curCell&&curCell.textContent.trim().length===1,'cursor-on-glyph'); - const laz=Q('[data-face="lazy-highlight"]'); - A(laz&&/underline/.test(laz.getAttribute('style')||''),'overlay-honors-style'); - A([...document.querySelectorAll('#mockframe .fr')].some(e=>e.textContent.trim()),'fringe-indicator-present'); - const mlbar=Q('[data-face="mode-line"]'); - A(mlbar&&/box-shadow/.test(mlbar.getAttribute('style')||''),'mode-line-box'); - UIMAP['line-number-current-line'].bold=true;buildMockFrame(); - const curNum=Q('[data-face="line-number-current-line"]'); - A(curNum&&/font-weight:\s*bold/.test(curNum.getAttribute('style')||''),'line-number-honors-weight'); - document.title='MOCKTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='mocktest';d.textContent='MOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}} -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();}} -if(location.hash==='#planetest'){let ok=true;const notes=[]; - document.getElementById('newhexstr').value='#67809c';openPicker();setPkModel('oklch');paintPicker(); - const sv=document.getElementById('sv'),cv=document.getElementById('svmask'),ctx=cv.getContext('2d'); - const [L,C,H]=readOklch(); - const expLeft=Math.min(1,C/OKLCH_CMAX)*sv.clientWidth,expTop=(1-L)*sv.clientHeight; - const gotLeft=parseFloat(document.getElementById('svcur').style.left),gotTop=parseFloat(document.getElementById('svcur').style.top); - if(Math.abs(gotLeft-expLeft)>2||Math.abs(gotTop-expTop)>2){ok=false;notes.push('crosshair off got '+gotLeft.toFixed(1)+','+gotTop.toFixed(1)+' exp '+expLeft.toFixed(1)+','+expTop.toFixed(1));} - const Coog=0.38,Loog=0.5,labO=oklch2oklab(Loog,Coog,H),oog=!inGamut(oklab2lrgb(labO.L,labO.a,labO.b)); - const oogX=Math.min(cv.width-2,Math.round((Coog/OKLCH_CMAX)*cv.width)),oogY=Math.round((1-Loog)*cv.height); - const dO=ctx.getImageData(oogX,oogY,1,1).data,greyO=Math.abs(dO[0]-0x15)<10&&Math.abs(dO[1]-0x12)<10&&Math.abs(dO[2]-0x0f)<10; - if(oog&&!greyO){ok=false;notes.push('OOG cell not masked rgb '+dO[0]+','+dO[1]+','+dO[2]);} - const inX=Math.round((0.03/OKLCH_CMAX)*cv.width),inY=Math.round(0.5*cv.height); - const dI=ctx.getImageData(inX,inY,1,1).data,greyI=Math.abs(dI[0]-0x15)<10&&Math.abs(dI[1]-0x12)<10&&Math.abs(dI[2]-0x0f)<10; - if(greyI){ok=false;notes.push('in-gamut cell rendered as OOG grey rgb '+dI[0]+','+dI[1]+','+dI[2]);} - document.title='PLANETEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='planetest';d.textContent='PLANETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -if(location.hash==='#oklchtest'){let ok=true;const notes=[]; - document.getElementById('newhexstr').value='#67809c';openPicker(); - const before=document.getElementById('newhexstr').value; - setPkModel('oklch'); - if(pkModel!=='oklch'){ok=false;notes.push('model not oklch');} - if(!document.getElementById('oklchctl').classList.contains('show')){ok=false;notes.push('oklch dials hidden');} - if(document.getElementById('newhexstr').value!==before){ok=false;notes.push('color changed on model switch: '+document.getElementById('newhexstr').value);} - pkMode='any';document.querySelector('.pmode button[data-m="aa"]').click(); - if(pkModel!=='oklch'){ok=false;notes.push('mask toggle reset model');} - if(pkMode!=='aa'){ok=false;notes.push('mask did not set aa');} - setPkModel('hsv'); - if(pkMode!=='aa'){ok=false;notes.push('model switch reset mask to '+pkMode);} - if(pkModel!=='hsv'){ok=false;notes.push('model not hsv after switch');} - setPkModel('oklch');setOklchInputs(0.591,0.052,251.6);pkOklchSet(); - const driven=document.getElementById('newhexstr').value,dl=oklab2oklch(srgb2oklab(driven)); - if(!(Math.abs(dl.L-0.591)<0.02&&Math.abs(dl.C-0.052)<0.02)){ok=false;notes.push('dials did not drive color: '+driven);} - const {clamped}=oklch2hex(0.7,0.4,140);setOklchInputs(0.7,0.4,140);pkOklchSet(); - if(!(clamped&&document.getElementById('pkclamp').classList.contains('show'))){ok=false;notes.push('clamp status missing for out-of-gamut C');} - document.title='OKLCHTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='oklchtest';d.textContent='OKLCHTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -if(location.hash==='#deltatest'){const save=PALETTE.slice();let ok=true;const notes=[];const W=()=>document.getElementById('palwarn'); - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue'],['#69829e','blue2']];renderPalette(); - const t1=W().textContent;if(!(W().style.display!=='none'&&/blue \/ blue2/.test(t1)&&/ΔE/.test(t1))){ok=false;notes.push('near-pair did not fire: '+t1);} - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue'],['#e8bd30','gold'],['#cb6b4d','terra']];renderPalette(); - if(W().style.display!=='none'){ok=false;notes.push('spread palette warned: '+W().textContent);} - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg']];for(let k=0;k<7;k++){const v=(0x67+k).toString(16).padStart(2,'0');PALETTE.push(['#'+v+'809c','c'+k]);}renderPalette(); - const tc=W().textContent;const nums=[...tc.matchAll(/ΔE (\d+\.\d+)/g)].map(m=>parseFloat(m[1])); - if(!/and \d+ more/.test(tc)){ok=false;notes.push('no cap suffix: '+tc);} - if(!(nums.length===5&&nums.every((n,k)=>k===0||n>=nums[k-1]))){ok=false;notes.push('not 5-capped ascending: '+nums.join(','));} - PALETTE=save;renderPalette(); - document.title='DELTATEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='deltatest';d.textContent='DELTATEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('newhexstr').value=hex;openPicker();pkReadout(hex); - const o=document.getElementById('pkoklch').textContent,a=document.getElementById('pkapca').textContent,w=document.getElementById('pkcon').textContent; - const lch=oklab2oklch(srgb2oklab(hex)); - const expO='OKLCH '+lch.L.toFixed(3)+' '+lch.C.toFixed(3)+' '+Math.round(lch.H)+'\u00b0'; - const expA='APCA Lc '+apca(hex,MAP['bg']).toFixed(0); - const r=contrast(hex,MAP['bg']),expW=r.toFixed(1)+' '+rating(r); - const wired=o===expO&&a===expA&&w===expW; - 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);} -// Worst-case readout gate (open with #contrasttest): a covered overlay face shows -// the floor over its foreground set and names the limiting foreground, an -// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". -if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); - MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000'; - UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; - buildUITable(); - const cell=document.getElementById('uicr-region'); - A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent)); - A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent)); - A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict'); - const fl=floor('#202830',fgSetForFace('region').set); - A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex); - A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg'); - const ml=document.getElementById('uicr-mode-line'); - A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)'); - A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent)); - MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable(); - const empty=document.getElementById('uicr-region'); - A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent)); - // A two-color face (own fg AND own bg) rates its own pair, never the ground bg. - UIMAP['mode-line']={fg:'#112233',bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false}; - buildUITable(); - const two=document.getElementById('uicr-mode-line'),twoWant=contrast('#112233','#aabbcc'); - A(two&&Math.abs(parseFloat(two.textContent)-twoWant)<0.06,'ui two-color face rates own fg-on-bg: got '+(two&&two.textContent.trim())+' want '+twoWant.toFixed(1)); - const tApp=Object.keys(APPS)[0],tFace=APPS[tApp].faces[0][0],savePF=JSON.parse(JSON.stringify(PKGMAP[tApp][tFace])); - Object.assign(PKGMAP[tApp][tFace],{fg:'#112233',bg:'#aabbcc',inherit:null});buildPkgTable(); - const prow=document.querySelector('#pkgbody tr[data-face="'+tFace+'"]'),pcell=prow&&prow.children[5]; - A(pcell&&Math.abs(parseFloat(pcell.textContent)-twoWant)<0.06,'pkg two-color face rates own fg-on-bg: got '+(pcell&&pcell.textContent.trim())+' want '+twoWant.toFixed(1)); - PKGMAP[tApp][tFace]=savePF;buildPkgTable(); - // A ground-bg change must not clobber a face's own preview bg, must leave a - // two-color ratio alone, and must re-rate a ground-dependent face's cell. - UIMAP['fringe']={fg:'#ddeeff',bg:null,bold:false,italic:false,underline:false,strike:false}; - buildUITable(); - MAP['bg']='#440000';applyGround(); - const pv=document.getElementById('uiprev-mode-line'); - A(pv&&pv.style.background==='rgb(170, 187, 204)','ground change keeps a face own preview bg: got '+(pv&&pv.style.background)); - const twoAfter=document.getElementById('uicr-mode-line'); - A(twoAfter&&Math.abs(parseFloat(twoAfter.textContent)-twoWant)<0.06,'ground change leaves a two-color ratio alone: got '+(twoAfter&&twoAfter.textContent.trim())); - const frc=document.getElementById('uicr-fringe'),frWant=contrast('#ddeeff','#440000'); - A(frc&&Math.abs(parseFloat(frc.textContent)-frWant)<0.06,'ground change re-rates a ground-dependent face: got '+(frc&&frc.textContent.trim())+' want '+frWant.toFixed(1)); - // A default-fg (p) change through the real syntax dropdown re-rates a face - // whose fg falls back to it. Drives the DOM so the handler wiring is pinned. - UIMAP['fringe']={fg:null,bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false}; - buildUITable(); - const pLocked=LOCKED.has('p');if(pLocked){LOCKED.delete('p');buildTable();} - const pdd=document.querySelector('#legbody tr[data-kind="p"] .cdd'); - if(pdd){pdd.click(); - const pHex=PALETTE.find(p=>p[0]!==MAP['p'])[0]; - const prow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.querySelector('.cddhx').textContent===pHex); - if(prow)prow.click(); - const pf=document.getElementById('uicr-fringe'),pfWant=contrast(pHex,'#aabbcc'); - A(prow&&pf&&Math.abs(parseFloat(pf.textContent)-pfWant)<0.06,'default-fg change re-rates a p-fallback face: got '+(pf&&pf.textContent.trim())+' want '+pfWant.toFixed(1)); - }else A(false,'syntax table has a p row with a dropdown'); - if(pLocked){LOCKED.add('p');buildTable();} - for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();applyGround(); - document.title='CONTRASTTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Bevel gate (open with #beveltest): released/pressed boxes derive their -// highlight and shadow from the face's effective bg per Emacs's relief -// algorithm, and pressed draws the shadow edge first. -if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveUI=JSON.parse(JSON.stringify(UIMAP)); - UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',bold:false,italic:false,underline:false,strike:false,box:{style:'released',width:1,color:null}}; - buildUITable(); - const pv=document.getElementById('uiprev-mode-line'); - const bs=pv&&pv.style.boxShadow; - A(bs&&bs.includes('rgb(113, 118, 127)'),'released highlight derives from the face bg (#71767f): '+bs); - A(bs&&bs.includes('rgb(15, 17, 22)'),'released shadow derives from the face bg (#0f1116): '+bs); - UIMAP['mode-line'].box={style:'pressed',width:1,color:null};paintUI('mode-line'); - const bs2=pv&&pv.style.boxShadow; - A(bs2&&bs2.includes('rgb(15, 17, 22)')&&bs2.includes('rgb(113, 118, 127)')&&bs2.indexOf('rgb(15, 17, 22)')<bs2.indexOf('rgb(113, 118, 127)'),'pressed swaps the pair (shadow edge first): '+bs2); - UIMAP['mode-line'].box={style:'line',width:1,color:'#ff0000'};paintUI('mode-line'); - A(pv&&pv.style.boxShadow.includes('rgb(255, 0, 0)'),'line style keeps its explicit color: '+(pv&&pv.style.boxShadow)); - for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable(); - document.title='BEVELTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe -// lightness band for a selected covered face and hides it when no face is selected. -if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveMAP=Object.assign({},MAP); - MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['bg']='#000000'; - document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch'); - setSafeFace('region'); - const band=document.getElementById('svsafe'); - A(band&&band.style.display==='block','safe band shows for an in-scope face'); - A(band&&parseFloat(band.style.height)>0,'safe band has a positive height: '+(band&&band.style.height)); - setSafeFace(''); - A(band&&band.style.display==='none','safe band hidden when no face is selected'); - for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP); - setPkModel('hsv');closePicker(); - document.title='SAFETEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Gone-rebind gate (open with #healtest): deleting a named color then recreating -// the name re-points the assignments stranded on the old hex to the new color. -if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx; - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable(); - const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue'); - A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button'); - if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click(); - A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted'); - A(lastGone['blue']==='#67809c','delete recorded the gone name->hex'); - document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor(); - A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']); - A(!('blue' in lastGone),'heal consumed the gone entry'); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel; - renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); - document.title='HEALTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Family-strip gate (open with #familytest): the palette renders as the pinned -// ground strip plus hue families, chips keep their controls, and renaming a color -// to anything leaves it in the same strip (grouping is by hex, not name). -if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx; - MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; - PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette(); - const strips=[...document.querySelectorAll('#pals .fstrip')]; - A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first'); - A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg'); - A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length); - const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red'); - A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls'); - const redFamily=redChip&&redChip.closest('.fstrip').dataset.family; - const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette(); - const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd'); - A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip'); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette(); - document.title='FAMILYTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Count-control gate (open with #counttest): the per-family count regenerates the -// family — count up adds symmetric steps, count down drops the extremes, a -// reference to a surviving step follows the new hex, a reference to a removed step -// is left on its old (now-gone) hex. -if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; - MAP['bg']='#000000';MAP['p']='#f0fef0'; - PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; - regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); - const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change - const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down - UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; - UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; - selectedIdx=null;renderPalette(); - setFamilyCount('#67809c',1); - const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase())); - A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down'); - A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex'); - const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; - A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); - setFamilyCount('#67809c',3); - const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); - const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); - A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); - document.title='COUNTTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Base-edit + ground-edit gate (open with #baseedittest): editing a family base -// recolors the whole family at the same count and references follow; editing a -// ground swatch writes the bg/fg assignment. -if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; - MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; - PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; - regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); - UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; - renderPalette();buildUITable(); - selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); - document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; - updateColor(); - const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral); - A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex'); - A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length)); - A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette'); - A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex'); - // ground edit: select bg, change hex, MAP.bg follows - selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a'); - document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground'; - updateColor(); - A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); - document.title='BASEEDITTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Round-trip gate (open with #roundtriptest): export stays a flat palette and -// import needs no family reconstruction, so export → import → export is identical. -if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const before=JSON.stringify(exportObj()); - applyImported(before); - const after=JSON.stringify(exportObj()); - A(before===after,'export → import → export is byte-identical'); - const obj=JSON.parse(after); - A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list'); - document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +initApp(); +addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();}); +BROWSER_GATES_J |
