const SAMPLES=SAMPLES_J, CATS=CATS_J, UI_FACES=UIFACES_J, APPS=APPS_J; const COLOR_NAMES=COLOR_NAMES_J; const FACE_DOCS=FACE_DOCS_J, SYNTAX_DOCS=SYNTAX_DOCS_J; // face/category -> docstring first line, for element hovers 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,'distant-fg':null,family:null,weight:null,slant:null,underline:null,strike:null,overline:null,box:null,inverse:false,extend:false,inherit:null,height: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 // the core verbatim and are used by name. function pname(n){return nameToHex(n,PALETTE);} function seedPkgmap(){return buildPkgmap(APPS,PALETTE);} let PKGMAP=seedPkgmap(); // Preview-locate registry (preview-locate spec). One cached, module-level // registry rebuilt once per assignment / import / reset / view-switch batch — at // the top of the two preview renderers (buildPkgPreview, buildMockFrame), which // every such path funnels through before spans render. Never rebuilt per hover or // per span. locate-onpane is recomputed from the current view at render time // (isLocateOnPane), never stored here. Built lazily (not at declaration): the // inlined buildLocateRegistry / UI_INHERIT from app-core.js are spliced below // this point, so an init call here would hit the const's temporal dead zone. let LOCATE_REG={}; function rebuildLocateRegistry(){LOCATE_REG=buildLocateRegistry(APPS,PKGMAP,UIMAP,MAP);return LOCATE_REG;} function esc(t){return t.replace(/&/g,'&').replace(//g,'>');} // Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, // plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. COLORMATH_J // Pure package-model + dropdown logic, inlined verbatim from app-core.js. The // wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here. APP_CORE_J // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. APP_UTIL_J // Pure palette-generator planner and browser-side generator panel. PALETTE_GENERATOR_CORE_J PALETTE_GENERATOR_UI_J // The contrast-cell readout shared by every table: a WCAG ratio colored by its // table verdict. Callers compute r for their own fg/bg. function verdictFor(r,target=4.5){return r>=target?'PASS':'FAIL';} function crHtml(r){return `${r.toFixed(1)}`;} // Effective fg/bg with the standard fallback: an unset foreground reads as the // default fg (MAP['p']), an unset background as the ground (MAP['bg']). All three // tiers resolve their raw value through these before measuring or rendering. function effFg(v){return v||MAP['p'];} function effBg(v){return v||MAP['bg'];} // The ground pair (background + default foreground), passed to every app-core // helper that needs to resolve ground roles. Was the literal {bg:MAP['bg'], // fg:MAP['p']} repeated across app.js, palette-actions.js, and the browser gates. function groundPair(){return {bg:MAP['bg'],fg:MAP['p']};} function cid(l){return l.replace(/\W/g,'');} function buildLangSel(){const s=document.getElementById('langsel');s.innerHTML='';for(const lang of Object.keys(SAMPLES).sort((a,b)=>a.localeCompare(b))){const o=document.createElement('option');o.value=lang;o.textContent=lang;s.appendChild(o);}if(SAMPLES['Elisp'])s.value='Elisp';} 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)html+=`${esc(t)}`; 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);}; buildMockFrame(); } CONTROLS_J // Expand/collapse every row in a table at once, then sync the per-row triangles. function setAllExpanded(tableId,expand){ const tb=document.getElementById(tableId);if(!tb)return; tb.querySelectorAll('tr.detailrow').forEach(d=>{d.style.display=expand?'':'none';const k=d.dataset.detailFor;if(k){expand?EXPANDED.add(k):EXPANDED.delete(k);}}); tb.querySelectorAll('.exptoggle').forEach(b=>{b.textContent=expand?'▼':'▶';b.classList.toggle('on',expand);}); } // The header-level expand/collapse-all toggle for a table. Its label and triangle // track the aggregate: any row open -> ▼ collapse all; all closed -> ▶ expand all. const EXPALL_TABLE={syntaxexpandall:'legbody',uiexpandall:'uibody',pkgexpandall:'pkgbody'}; function syncExpandAllBtns(){ for(const id in EXPALL_TABLE){const btn=document.getElementById(id);const tb=document.getElementById(EXPALL_TABLE[id]);if(!btn||!tb)continue; const anyOpen=[...tb.querySelectorAll('tr.detailrow')].some(d=>d.style.display!=='none'); btn.textContent=anyOpen?'▼ collapse all':'▶ expand all';} } function toggleAllExpanded(id){ const tableId=EXPALL_TABLE[id],tb=document.getElementById(tableId);if(!tb)return; const anyOpen=[...tb.querySelectorAll('tr.detailrow')].some(d=>d.style.display!=='none'); setAllExpanded(tableId,!anyOpen);syncExpandAllBtns(); } // Column count for a table's detail-row colspan, read from its header so the // expander never hardcodes a width that drifts when a column is added. function tableColCount(tableId){const h=document.querySelector('#'+tableId+' thead tr');return h?h.cells.length:1;} // 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();buildPkgTable();// buildPkgTable self-guards when #pkgbody is absent } 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');updateViewLockIndicators();} 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=>{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]]=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]]=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 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 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=cssWeight(s.weight);exTd.style.fontStyle=s.slant||'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,defaultHex:rowFg()}); const bgd=mkColorDropdown(ddList(sf.bg||''),sf.bg||'',hex=>{const s=syntaxFace(kind);s.bg=hex||null;styleEx();styleCr();renderCode();repaintCovered();},{compact:true,defaultHex:rowBg()}); styleEx();styleCr(); const stTd=document.createElement('td'); const stCtls=mkStyleControls(syntaxFace(kind),()=>{styleEx();renderCode();},{defaultHex:rowFg()}); const stCluster=document.createElement('div');stCluster.className='stylecluster';stCtls.forEach(c=>stCluster.appendChild(c));stTd.appendChild(stCluster); 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 exp=mkExpander(syntaxFace(kind),tableColCount('legtable'),()=>{styleEx();renderCode();},{expandKey:kind,showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:rowFg(),ndCheck:()=>overflowNonDefault(syntaxFace(kind),DEFAULT_SYNTAX[kind],true)}); exp.detail.dataset.detailFor=kind; const lkTd=mkLockCell(kind,[dd,bgd,...stCtls,boxCtl,...exp.locks]); const c2=document.createElement('td');c2.className='cat';c2.title=composeHoverTitle(SYNTAX_DOCS[kind],c2.title);c2.appendChild(exp.btn); const c2lbl=document.createElement('span');c2lbl.textContent=' '+label;c2lbl.style.cursor='pointer';c2lbl.title='flash this category in the code';c2lbl.onclick=()=>flashTokens(kind);c2.appendChild(c2lbl); tr.appendChild(lkTd);tr.appendChild(c2);tr.appendChild(c0);tr.appendChild(cB);tr.appendChild(stTd);tr.appendChild(cX);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);tb.appendChild(exp.detail);} updateLockToggle('syntax');syncExpandAllBtns(); } 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;GEN_SELECTION=null;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();renderGeneratorPreview();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],oldRole=groundRoleOfEntry(PALETTE[i],groundPair()); 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;} 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,groundPair()).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();refreshPaletteState();notify('updated "'+newName+'"',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 function pkThresh(){return pkMode==='aa'?4.5:pkMode==='aaa'?7:0;} function drawMask(){const cv=document.getElementById('svmask');if(!cv)return;const sv=document.getElementById('sv'),w=cv.width=sv.clientWidth,h=cv.height=sv.clientHeight,ctx=cv.getContext('2d');ctx.clearRect(0,0,w,h);const T=pkThresh();if(!T)return;ctx.fillStyle='rgba(8,7,6,0.66)';const step=4;for(let x=0;x{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)||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);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());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(); const sf=document.getElementById('safefor');if(sf&&sf.options.length<=1)COVERED_FACES.forEach(f=>{const o=document.createElement('option');o.value=f;o.textContent=f;sf.appendChild(o);}); pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();const fx=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height)); if(pkModel==='oklch'){setOklchInputs(1-fy,fx*OKLCH_CMAX,readOklch()[2]);pkOklchSet();}else{pkS=fx;pkV=1-fy;pkSet();}}); pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();const fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height)); if(pkModel==='oklch'){const [L,C]=readOklch();setOklchInputs(L,C,fy*360);pkOklchSet();}else{pkH=fy*360;pkSet();}}); document.querySelectorAll('.pmode button').forEach(b=>b.onclick=()=>{pkMode=b.dataset.m;document.querySelectorAll('.pmode button').forEach(x=>x.classList.toggle('on',x===b));paintPicker();buildPkChips();}); document.querySelectorAll('.pmodel button').forEach(b=>b.onclick=()=>setPkModel(b.dataset.pm)); [['okL','okLn',3],['okC','okCn',3],['okH','okHn',0]].forEach(([r,n,dp])=>{ const re=document.getElementById(r),ne=document.getElementById(n); if(re)re.addEventListener('input',()=>{if(ne)ne.value=(+re.value).toFixed(dp);pkOklchSet();}); if(ne)ne.addEventListener('input',()=>{if(re)re.value=ne.value;pkOklchSet();});});} 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,columnIdOf([h,name])]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;GEN_SELECTION=null;closePicker(); refreshPaletteState({code:healed,ground:healed,pkgPreview:healed}); renderGeneratorPreview(); 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(){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';} // Export the theme JSON. Prefer the File System Access API (showSaveFilePicker) // so re-exporting overwrites the chosen file in place -- a blob download routes // through the browser's downloads folder, which uniquifies a re-save as // "name (1).json" rather than replacing it. Fall back to the blob download where // the API is absent (mirrors importTheme's showOpenFilePicker/fileinput fallback). async function exportTheme(){ const data=JSON.stringify(exportObj(),null,1); if(!window.showSaveFilePicker){const blob=new Blob([data],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fileSlug()+'.json';a.click();return;} try{const h=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); const w=await h.createWritable();await w.write(data);await w.close(); notify('saved "'+fileSlug()+'.json"',false); }catch(e){if(e&&e.name!=='AbortError')notify('export 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.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),migrateLegacyFace(d.syntax[k]||{}));});syncAllSyntaxCache(); LOCKED=new Set(d.locks||[]); if(d.ui)for(const k in d.ui)UIMAP[k]=Object.assign(uiFaceBlank(),migrateLegacyFace(d.ui[k])); PKGMAP=seedPkgmap();if(d.packages)mergePackagesInto(PKGMAP,d.packages); 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);updateTitle();}catch(e){alert('bad theme file: '+e.message);}}; r.readAsText(f);ev.target.value='';} 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());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']);UI_FACES.forEach(([f])=>{if(document.getElementById('uiprev-'+f))paintUI(f);});} function uf(f){return UIMAP[f]||{};} // Map a weight name to a CSS font-weight for the live previews. The named // weights light/medium/semibold/heavy aren't CSS keywords, so resolve to the // numeric scale; an unset weight renders normal. // cssWeight, boxCss, faceDecoration, and faceCss live in app-core.js now. // udeco keeps its own (untrimmed) decoration form, so it stays here. function udeco(o){return 'font-weight:'+cssWeight(o.weight)+';font-style:'+(o.slant||'normal')+';text-decoration:'+((o.underline?'underline ':'')+(o.strike?'line-through':'')||'none');} function syntaxStyle(k){const s=syntaxFace(k),fg=(k==='bg'?MAP['p']:resolveSyntaxFg(k,SYNTAX,MAP['p'])),bg=s.bg||null;return faceCss(s,fg,bg,{boxBg:bg||MAP['bg']});} // 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). // Box control: a 2x2 cluster of radio buttons for the four box styles (no box / // line / pressed / raised), plus a compact color swatch shown only while a box // style is active. Replaces the old wide select+swatch to reclaim column width. // Box control: a 2x2 cluster of the four box styles (no box / line / pressed / // raised) plus a compact color swatch shown while a style is active. Shares the // cluster/dropdown/paint machinery with mkLineStyleControl; it differs only in // that its state object carries `width`, so it passes a toState builder. function mkBoxControl(get,set,opts={}){ return mkLineStyleControl( [['','no box',''],['line','line box','□'],['pressed','pressed','▼'],['released','raised','▲']], get,set, Object.assign({styled:true,toState:(v,cur)=>({style:v,width:(cur&&cur.width)||1,color:(cur&&cur.color)||null})},opts));} function flashRow(tr){if(!tr)return;tr.scrollIntoView({block:'center',behavior:'smooth'});tr.classList.remove('flash');void tr.offsetWidth;tr.classList.add('flash');} // Unified preview-locate click dispatch (preview-locate spec, Phases 4-5). One // handler for every preview surface replaces the per-surface data-face branches: // find the clicked data-face element, resolve its owner (data-owner-app, or // DEFAULTOWNER for a bare span emitted by the generic / auto-dim / UI-mock // renderers that pre-date previewSpan), and flash its assignment row only when it // is on-pane. An owner-tagged off-pane / unassigned element is inert; a bare span // is a current-pane element by construction, so it stays clickable. No persistent // selection — flashRow is scroll + flash only. The data-k syntax-click path stays // separate (handled by each caller before delegating here). function locateClick(e,defaultOwner){ const u=e.target.closest('[data-face]');if(!u)return; if(u.dataset.ownerApp&&!u.classList.contains('locate-onpane'))return; const owner=u.dataset.ownerApp||defaultOwner; if(owner==='@ui')flashUi(u.dataset.face);else flashPkg(u.dataset.face); } 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 // that maps to several preview spans still lands the viewport on the first. function flashEls(els){els=[...els];if(!els.length)return;els[0].scrollIntoView({block:'nearest',inline:'nearest',behavior:'smooth'});els.forEach(el=>{el.classList.remove('flashtok');void el.offsetWidth;el.classList.add('flashtok');});} function flashTokens(kind){const sp=document.querySelectorAll('#codepre [data-k="'+kind+'"]');if(sp.length){flashEls(sp);return;}const row=document.querySelector('#legbody tr[data-kind="'+kind+'"]');if(row)flashEl(row.querySelector('.ex'));} function flashAssign(k){flashRow(document.querySelector(`#legbody tr[data-kind="${k}"]`));} 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 `${esc(t)}`;} function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv;return faceCss(o,fg,bg,{noBg:opts.noBg,boxBg:bg||MAP['bg']});} // Size a preview pane to its faces table, minus the label bar above it. Shared by // the UI mock and the package preview, which differ only in their element IDs. function syncPaneHeight(tableId,paneId){const t=document.getElementById(tableId),m=document.getElementById(paneId);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; rebuildLocateRegistry(); const bg=MAP['bg'],fg=MAP['p']; const ln=uf('line-number'),lnc=uf('line-number-current-line'),hl=uf('hl-line'),hil=uf('highlight'),reg=uf('region'),isr=uf('isearch'),isf=uf('isearch-fail'),laz=uf('lazy-highlight'),par=uf('show-paren-match'),parx=uf('show-paren-mismatch'),cur=uf('cursor'),ml=uf('mode-line'),mli=uf('mode-line-inactive'),mlh=uf('mode-line-highlight'),mb=uf('minibuffer-prompt'),frng=uf('fringe'),vb=uf('vertical-border'),lnk=uf('link'),err=uf('error'),wrn=uf('warning'),suc=uf('success'); const lines=[ {t:[['cmd',';; '],['cm','init.el - your config']]}, {t:[['punc','('],['kw','require'],['p',' '],['con',"'cl-lib"],['punc',')']]}, {t:[]}, {t:[['punc','('],['kw','defun'],['p',' '],['fnd','cj/greet'],['p',' '],['punc','('],['var','name'],['punc',')']]}, {t:[['p',' '],['punc','('],['fnc','message'],['p',' '],['str','"hi %s"'],['p',' '],['var','name'],['punc','))']],cur:1}, {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','highlights'],['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} ]; // An overlay face (region, highlight, isearch, lazy-highlight) merges the way // Emacs does: its background applies, and its foreground overrides the tokens // only when set — otherwise the underlying syntax colors show through. const overlay=(tokens,face,dface)=>{ const inner=face.fg ? `${tokens.map(([,t])=>esc(t)).join('')}` : tokens.map(([k,t])=>mockSpan(k,t)).join(''); return `${inner}`; }; // 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=>`${esc(ch)}`; for(const [k,t] of tokens){ const m=placed?-1:t.search(/\S/); if(m>=0){ if(m>0)out+=mockSpan(k,t.slice(0,m)); out+=cell(t[m]); if(t.length>m+1)out+=mockSpan(k,t.slice(m+1)); placed=true; } else out+=mockSpan(k,t); } if(!placed)out+=cell(' '); return out; }; let buf=''; lines.forEach((L,i)=>{ const isc=L.cur; const nFg=isc?(resolveUiAttr('line-number-current-line','fg',UIMAP)||fg):(ln.fg||fg), nBg=isc?(resolveUiAttr('line-number-current-line','bg',UIMAP)||'transparent'):(ln.bg||'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.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.hl)cd=overlay(L.t,hl,'hl-line'); else if(L.paren)cd=L.t.map(([k,t],j)=>j===L.t.length-1?`${esc(t)}`: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):'')+`${esc(bad)}`;}).join(''); else cd=L.t.map(([k,t])=>mockSpan(k,t)).join(''); const nFace=isc?'line-number-current-line':'line-number'; buf+=`
${L.cont?'↪':''}${i+1}${cd||' '}
`; }); let html=`
${buf}
`; const mlhStyle=uiCss(mlh,mlh.fg||ml.fg||bg,mlh.bg||ml.bg||fg); html+=`
init.el (Emacs Lisp) L5 git:main
`; html+=`
*Messages* (Fundamental)
`; html+=`
I-search: count zzz [no match]
`; html+=`
https://gnu.org error warning ok
`; fr.innerHTML=html;fr.style.background=bg;fr.style.color=fg; fr.onclick=(e)=>{if(e.target.closest('[data-face]')){locateClick(e,'@ui');return;}const k=e.target.closest('[data-k]');if(k)flashAssign(k.dataset.k);}; } // All three tiers share one dropdown — the swatch div from mkColorDropdown. The // native