From d93560446f954a44890b8472f90d57c3080993df Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 13 Jun 2026 17:17:31 -0500 Subject: Refactor theme studio palette tests --- scripts/theme-studio/README.md | 27 +- scripts/theme-studio/app-core.js | 42 ++- scripts/theme-studio/app.js | 649 +------------------------------- scripts/theme-studio/browser-gates.js | 428 +++++++++++++++++++++ scripts/theme-studio/generate.py | 8 + scripts/theme-studio/palette-actions.js | 204 ++++++++++ scripts/theme-studio/test-app-core.mjs | 60 +++ scripts/theme-studio/test_generate.py | 7 + scripts/theme-studio/theme-studio.html | 89 +++-- 9 files changed, 835 insertions(+), 679 deletions(-) create mode 100644 scripts/theme-studio/browser-gates.js create mode 100644 scripts/theme-studio/palette-actions.js (limited to 'scripts/theme-studio') diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md index c52d531f..54a1e985 100644 --- a/scripts/theme-studio/README.md +++ b/scripts/theme-studio/README.md @@ -48,7 +48,9 @@ the spliced page script, and the browser hash gates in headless Chrome Chromium-family browser; without one they report SKIPPED rather than passing silently. The pure color math and the extracted picker logic (`planeCell`, `paletteWarnings`) live in `colormath.js` so they are unit-tested directly in -Node; the DOM glue is covered by the browser hash gates. +Node; palette-column plans and lock-set plans live in `app-core.js` so edge +cases are unit-tested directly. The DOM glue is covered by the browser hash +gates. ## Files @@ -57,6 +59,9 @@ Node; the DOM glue is covered by the browser hash gates. - `theme-studio.template.html` — static page shell with placeholders for the inlined CSS/JS/data. Edit here for layout markup. - `face_data.py` — bespoke package face lists and seed defaults. +- `palette-actions.js` — stateful palette-panel actions and rendering, inlined + into the generated page. +- `browser-gates.js` — the browser hash-gate test harness, also inlined. - `app_inventory.py`, `face_specs.py`, `default_faces.py` — generator helpers for package inventory, face-spec defaults, and captured Emacs defaults. - `samples.py` — the six language code samples and the default syntax @@ -78,7 +83,10 @@ Three tiers of faces, plus the palette: by hex or with the in-page color picker (saturation/value square, hue slider, palette reuse chips, live contrast readout, and an any / AA+ / AAA legibility mask). Remove and rename per chip; - the colors serving as background and foreground are locked. + the colors serving as background and foreground are locked. `clear palette` + removes every non-ground color and leaves only the `bg` and `fg` tiles; existing + face assignments remain on their old hexes and show as "(gone)" until a color + with the same name is recreated. The picker also shows perceptual readouts beside the WCAG ratio: the OKLCH coordinates (lightness, chroma, hue°) and the APCA Lc contrast against the @@ -96,10 +104,12 @@ Three tiers of faces, plus the palette: - **Syntax** — every font-lock / tree-sitter category (keyword, string, function, type, comment, and the rest), each with normal/bold/italic and a contrast rating. Click a category to flash its tokens in the code; click a - token to flash its row. + token to flash its row. `lock all` flips to `unlock all` when every row in the + tier is locked; `clear unlocked` leaves locked rows untouched. - **UI faces** — cursor, region, mode-line, fringe, line numbers, isearch, paren match, link, error/warning/success, and the rest, foreground and background - per face, shown in a live mock Emacs buffer. + per face, shown in a live mock Emacs buffer. The same lock-all and clear + unlocked controls apply to the UI face tier. - **Package faces** — per-package face tables with a live preview (below). ## Color columns @@ -114,6 +124,11 @@ derived from hue, chroma, lightness, or the visible color name. Renaming a color only changes its label, so a renamed tile stays in its original column. Older two-field palette entries still load by falling back to the generated-name stem (`blue-1`, `blue`, `blue+1` -> `blue`). + Generic Emacs names like `color-22` stay separate base columns unless they + already carry an explicit column id. Numbered named colors such as `blue1`, + `grey80`, `orange3`, and `orchid4` group by their text stem. Imported names + that begin with `bg` or `fg` are normal colors unless they are exact ground + endpoints or explicitly use the `ground` column id. - **The count control** under each non-ground column sets how many steps sit on each side of the column's base. Setting N regenerates the column as a symmetric base ±N tonal ramp via `ramp()` — lighter and darker steps on the base's hue @@ -159,7 +174,9 @@ Pick an application from the dropdown to edit its faces. Each row has a foreground and background dropdown, bold/italic toggles, an `inherit` dropdown (base faces like `fixed-pitch`/`link` plus the app's own faces), a relative height stepper, a contrast readout, and a per-face reset. There's a per-app -reset and a text filter for the large sets. +reset and a text filter for the large sets. Package `lock all` / `unlock all` +applies to the whole currently selected package, not only the rows visible under +the text filter. Twenty applications have bespoke previews that exercise nearly all of their faces: org-mode (a document plus an agenda view), magit (a status buffer plus diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 1a4a121f..5e889bed 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -160,6 +160,46 @@ function nameOfGroundRole(palette,ground,role){ return found?found[1]:null; } +function normalizePaletteEntryCore(entry){ + const hex=entry&&entry[0],name=(entry&&entry[1])||'color'; + return [hex,name,(entry&&entry[2])||columnIdOf(entry)]; +} + +function groundColumnMembersFromPalette(palette,ground){ + const byRole={bg:null,fg:null,steps:[]}; + for(const entry of palette){ + const role=groundRoleOfEntry(entry,ground); + if(role==='bg'||role==='fg')byRole[role]={hex:entry[0],name:entry[1]}; + else if(role==='step')byRole.steps.push({hex:entry[0],name:entry[1]}); + } + const stepIndex=m=>{const x=(m.name||'').match(/^ground-(\d+)$/i);return x?parseInt(x[1],10):Infinity;}; + byRole.steps.sort((a,b)=>stepIndex(a)-stepIndex(b)); + return [byRole.bg||{hex:ground&&ground.bg,name:'bg'},...byRole.steps,byRole.fg||{hex:ground&&ground.fg,name:'fg'}].filter(m=>m.hex); +} + +function clearPalettePlan(palette,ground){ + const normalized=palette.map(normalizePaletteEntryCore),removed=[],keep=[]; + normalized.filter(entry=>!groundRoleOfEntry(entry,ground)).forEach(([hex,name])=>{if(name)removed.push({hex,name});}); + const addEndpoint=(role,hex,name)=>{ + const found=normalized.find(entry=>groundRoleOfEntry(entry,ground)===role); + if(found)keep.push(found);else if(hex)keep.push([hex,name,'ground']); + }; + addEndpoint('bg',ground&&ground.bg,'bg'); + addEndpoint('fg',ground&&ground.fg,'fg'); + return {palette:keep,removed}; +} + +function areAllLocked(keys,locked){ + const has=k=>locked instanceof Set?locked.has(k):Array.isArray(locked)&&locked.includes(k); + return !!(keys&&keys.length)&&keys.every(has); +} +function lockToggleLabel(keys,locked){return areAllLocked(keys,locked)?'unlock all':'lock all';} +function toggleLockSet(keys,locked){ + const next=new Set(locked||[]),all=areAllLocked(keys,next); + (keys||[]).forEach(k=>all?next.delete(k):next.add(k)); + return next; +} + // Group a flat palette into the ground strip plus structural columns. ground is // {bg,fg}; those endpoint hexes form the pinned ground column even when absent // from the palette, and ground-N entries are reserved for that column. Everything @@ -241,4 +281,4 @@ function paletteOptionList(cur,palette,ground){ return out; } -export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry }; +export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, areAllLocked, lockToggleLabel, toggleLockSet }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 25b63d56..7f674a24 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -106,13 +106,12 @@ function pkgLockKeys(){const app=curApp();return APPS[app].faces.map(f=>'pkg:'+a 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; - const keys=tierLockKeys(tier),all=keys.length&&keys.every(k=>LOCKED.has(k)); - b.textContent=all?'unlock all':'lock all'; + b.textContent=lockToggleLabel(tierLockKeys(tier),LOCKED); } function updateLockToggles(){updateLockToggle('syntax');updateLockToggle('ui');updateLockToggle('pkg');} function toggleAllLocks(tier){ - const keys=tierLockKeys(tier),all=keys.length&&keys.every(k=>LOCKED.has(k)); - keys.forEach(k=>all?LOCKED.delete(k):LOCKED.add(k)); + 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); @@ -130,19 +129,6 @@ function clearUnlockedPkg(){ clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]=normalizePkgFace({source:'cleared'},'cleared');}); pkgChanged();notify('cleared unlocked '+app+' faces to default',false); } -function clearPalette(){ - normalizePalette(); - const keep=[],ground={bg:MAP['bg'],fg:MAP['p']}; - PALETTE.filter(entry=>!groundRoleOfEntry(entry,ground)).forEach(([hex,name])=>{if(name)lastGone[name.toLowerCase()]=hex;}); - const addEndpoint=(role,hex,name)=>{ - const found=PALETTE.find(entry=>groundRoleOfEntry(entry,ground)===role); - keep.push(found||[hex,name,'ground']); - }; - addEndpoint('bg',MAP['bg'],'bg');addEndpoint('fg',MAP['p'],'fg'); - PALETTE=keep;selectedIdx=null; - renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();renderCode();applyGround(); - notify('cleared palette to bg and fg',false); -} function buildTable(){ const tb=document.getElementById('legbody');tb.innerHTML=''; for(const [kind,label,ex] of CATS){ @@ -170,211 +156,7 @@ function buildTable(){ 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;} -function normalizePaletteEntry(entry){ - const hex=entry&&entry[0],name=(entry&&entry[1])||'color'; - return [hex,name,(entry&&entry[2])||columnIdOf(entry)]; -} -function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);} -// The ground column is explicit: bg pins the top endpoint, fg pins the bottom -// endpoint, and generated ground-N steps live between them. -function groundColumnMembers(){ - const ground={bg:MAP['bg'],fg:MAP['p']}; - const byRole={bg:null,fg:null,steps:[]}; - for(const entry of PALETTE){ - const role=groundRoleOfEntry(entry,ground); - if(role==='bg'||role==='fg')byRole[role]={hex:entry[0],name:entry[1]}; - else if(role==='step')byRole.steps.push({hex:entry[0],name:entry[1]}); - } - const stepIndex=m=>{const x=(m.name||'').match(/^ground-(\d+)$/i);return x?parseInt(x[1],10):Infinity;}; - byRole.steps.sort((a,b)=>stepIndex(a)-stepIndex(b)); - return [byRole.bg||{hex:MAP['bg'],name:'bg'},...byRole.steps,byRole.fg||{hex:MAP['p'],name:'fg'}]; -} -function groundSpanCount(){return PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step').length;} -function groundSpanControl(){ - const d=document.createElement('div');d.className='fcount'; - d.innerHTML=`span `; - d.querySelector('input').onchange=(e)=>setGroundSpan(Math.max(0,Math.min(8,parseInt(e.target.value,10)||0))); - return d; -} -function setGroundSpan(n){ - const old=PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step'); - const bg=srgb2oklab(MAP['bg']),fg=srgb2oklab(MAP['p']); - const entries=[]; - for(let i=1;i<=n;i++){ - const t=i/(n+1); - const lab={L:bg.L+(fg.L-bg.L)*t,a:bg.a+(fg.a-bg.a)*t,b:bg.b+(fg.b-bg.b)*t}; - entries.push([lrgb2hex(oklab2lrgb(lab.L,lab.a,lab.b)),'ground-'+i,'ground']); - } - for(const [oldHex,oldName] of old){ - const next=entries.find(([,name])=>name===oldName); - if(next&&next[0].toLowerCase()!==oldHex.toLowerCase())repointHex(oldHex,next[0]); - } - for(let i=PALETTE.length-1;i>=0;i--)if(groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']})==='step')PALETTE.splice(i,1); - let at=PALETTE.findIndex(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='bg'); - if(at<0)at=0; else at+=1; - PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); - selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); - notify('set ground span to '+n,false); -} -// 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='
too-similar colors
'; - html+=warnings.map(p=>`
${esc(p.aName+' / '+p.bName)} — \u0394E ${p.dE.toFixed(3)}, hard to distinguish
`).join(''); - if(overflow>0)html+=`
and ${overflow} more
`; - 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 role=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']}); - const locked=(role==='bg'||role==='fg'); - 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?`🔒`:``; - d.innerHTML=`${rm}
${hex}
`; - 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; -} -function paletteIndexByHexName(hex,name){ - for(let i=0;im.hex.toLowerCase()===f.base.toLowerCase())||f.members[0]; - const i=paletteIndexByHexName(baseMember.hex,baseMember.name); - if(i>=0)selectColor(i); -} -function isGroundEntry(entry){ - return !!groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']}); -} -function moveColumn(columnId,dir){ - normalizePalette(); - const columns=sortColumns(columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns); - const pos=columns.findIndex(f=>f.column===columnId); - const next=columns[pos+dir]; - if(pos<0||!next)return; - const moving=[],rest=[]; - PALETTE.forEach(entry=>{ - if(!isGroundEntry(entry)&&columnIdOf(entry)===columnId)moving.push(entry); - else rest.push(entry); - }); - const nextPositions=[]; - rest.forEach((entry,i)=>{if(!isGroundEntry(entry)&&columnIdOf(entry)===next.column)nextPositions.push(i);}); - if(!nextPositions.length)return; - const at=dir<0?nextPositions[0]:nextPositions[nextPositions.length-1]+1; - PALETTE=rest.slice(0,at).concat(moving,rest.slice(at)); - selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); - notify('moved "'+columnId+'" '+(dir<0?'left':'right'),false); -} -function columnHeader(f,position,count){ - const h=document.createElement('div');h.className='fhead'; - const label=(f.members.find(m=>m.hex.toLowerCase()===f.base.toLowerCase())||{}).name||f.column||f.base; - h.innerHTML=``; - h.querySelector('.ctitle').textContent=label; - h.querySelector('.ctitle').onclick=()=>selectColumnBase(f); - h.querySelector('.left').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,-1);}; - h.querySelector('.right').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,1);}; - return h; -} -// Render the palette as structural color columns: pinned ground column, then -// first-seen palette columns. Grouping uses the stable column id stored on each -// palette entry, so renaming a color never moves it. -function renderPalette(){ - normalizePalette(); - const p=document.getElementById('pals');p.innerHTML=''; - const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - const {ground,columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); - const used=new Set(); - const idxOf=(hex,name)=>{for(let i=0;i{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; - if(ground.length){ - const gs=strip(' ground');gs.dataset.column='ground'; - const gh=document.createElement('div');gh.className='fhead';gh.textContent='ground';gs.appendChild(gh); - gs.appendChild(groundSpanControl()); - groundColumnMembers().forEach(m=>{ - const i=idxOf(m.hex,m.name); - if(i>=0)gs.appendChild(paletteChip(i,nearest)); - else{const tc=textOn(m.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=m.hex;sw.title=(m.name||'ground')+' '+m.hex; - sw.innerHTML=`
${m.hex}
`;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. - const ordered=sortColumns(columns); - ordered.forEach((f,pos)=>{ - const s=strip('');s.dataset.column=f.column||f.base; - s.appendChild(columnHeader(f,pos,ordered.length)); - s.appendChild(columnCountControl(f)); - f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); - }); - renderPaletteWarnings(warnings,overflow); - buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); -} -// The per-column count control under a chromatic strip. Its value is the column's -// current per-side reach; setting N regenerates the column as base ±N. -function columnCountControl(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 ± `; - d.querySelector('input').onchange=(e)=>setColumnCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); - return d; -} -// Regenerate a column 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 regenColumnInPlace(oldHexes,baseHex,baseName,n,columnId){ - const r=regenColumn(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=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); - const col=columnId||columnStem(baseName); - const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset)),col]); - PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); - for(const [o,nw] of plan.map)repointHex(o,nw); - return plan.removed.length; -} -function setColumnCount(baseHex,n){ - const {columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); - const column=columns.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); - if(!column)return; - const baseName=(column.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; - const removed=regenColumnInPlace(column.members.map(m=>m.hex),baseHex,baseName,n,column.column); - 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);} @@ -1034,425 +816,4 @@ function srtTable(tbId,col){tableSort[tbId]={col,asc:!(tableSort[tbId]&&tableSor 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:(xy?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); -} -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');} - {LOCKED.clear();buildTable();const b=document.getElementById('syntaxlocktoggle');A(b&&b.textContent==='lock all','syntax toggle starts as lock all');b.click(); - A(syntaxLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','syntax lock-all locks every syntax row and flips label');b.click(); - A(syntaxLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','syntax unlock-all clears every syntax lock and flips label');} - {LOCKED.clear();buildUITable();const b=document.getElementById('uilocktoggle');A(b&&b.textContent==='lock all','ui toggle starts as lock all');b.click(); - A(uiLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','ui lock-all locks every UI row and flips label');b.click(); - A(uiLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','ui unlock-all clears every UI lock and flips label');} - {LOCKED.clear();buildPkgTable();const b=document.getElementById('pkglocktoggle');A(b&&b.textContent==='lock all','pkg toggle starts as lock all');b.click(); - A(pkgLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','pkg lock-all locks every current package row and flips label');b.click(); - A(pkgLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','pkg unlock-all clears every current package lock and flips label');} - 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&&/background:\s*(?!transparent)/.test(laz.getAttribute('style')||''),'overlay-honors-background-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'); - UIMAP['region'].bold=false;buildUITable(); - const uiBold=[...document.querySelectorAll('#uibody tr')].find(r=>r.dataset.face==='region').querySelector('.sbtn[title="bold"]'); - A(uiBold&&!uiBold.classList.contains('on'),'ui style button starts off when model is false'); - uiBold.click(); - A(uiBold.classList.contains('on')&&UIMAP['region'].bold===true,'ui style button visual state turns on with model'); - uiBold.click(); - A(!uiBold.classList.contains('on')&&UIMAP['region'].bold===false,'ui style button visual state turns off with model'); - const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].bold=false;buildPkgTable(); - const pkgBtn=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] .sbtn[title="bold"]'); - A(pkgBtn()&&!pkgBtn().classList.contains('on'),'pkg style button starts off when model is false'); - pkgBtn().click(); - A(pkgBtn()&&pkgBtn().classList.contains('on')&&PKGMAP[app][face].bold===true,'pkg style button visual state turns on after rebuild'); - 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)); - CATS.forEach(c=>{if(c[0]!=='bg'&&c[0]!=='p')MAP[c[0]]='';}); - 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)'){if(!c){ok=false;notes.push(n);}}; - const box=document.createElement('div'); - box.innerHTML=renderOrgPreview(); - const headline=[...box.querySelectorAll('[data-face="org-headline-todo"]')].find(e=>e.textContent.includes('Heading three')); - A(!!headline&&headline.previousElementSibling&&headline.previousElementSibling.dataset.face==='org-todo','org headline-todo follows a TODO keyword span'); - box.innerHTML=renderFlycheckPreview(); - const delim=[...box.querySelectorAll('[data-face="flycheck-error-delimiter"]')].map(e=>e.textContent).join(''); - const enclosed=[...box.querySelectorAll('[data-face="flycheck-delimited-error"]')].map(e=>e.textContent).join(''); - A(delim==='[]','flycheck delimiters use flycheck-error-delimiter'); - A(enclosed==='err','flycheck enclosed text uses flycheck-delimited-error'); - box.innerHTML=renderErcPreview(); - const own=[...box.querySelectorAll('[data-face="erc-input-face"]')].some(e=>e.textContent.includes('hello everyone')); - const bob=[...box.querySelectorAll('[data-face="erc-default-face"]')].some(e=>e.textContent.includes('hi craig')); - A(own,'erc own sent message uses erc-input-face'); - A(bob,'erc remote message uses erc-default-face'); - document.title='PREVIEWLINKTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='previewlinktest';d.textContent='PREVIEWLINKTEST '+(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);} -// Column-strip gate (open with #columntest): the palette renders as a pinned -// ground column plus structural columns, chips keep their controls, and renaming -// a color leaves it in the same strip because the column id is stable. -if(location.hash==='#columntest'||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),saveG=Object.assign({},lastGone),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].dataset.column==='ground','ground column is pinned first'); - A(strips[0].querySelectorAll('.pchip').length===2,'ground column carries bg + fg endpoints'); - A(!!strips[0].querySelector('.fhead + .fcount + .pchip'),'span control sits between header and tiles for ground'); - A(strips.length>=4,'ground + red + blue + gray columns, got '+strips.length); - const blueHead=strips.find(s=>s.dataset.column==='blue')&&strips.find(s=>s.dataset.column==='blue').querySelector('.ctitle'); - A(!!blueHead,'normal column header has a selectable title'); - if(blueHead)blueHead.click(); - A(selectedIdx!==null&&PALETTE[selectedIdx][1]==='blue'&&document.getElementById('newhexstr').value.toLowerCase()==='#3a6ea5','clicking a column title selects its base color'); - const blueRight=strips.find(s=>s.dataset.column==='blue')&&strips.find(s=>s.dataset.column==='blue').querySelector('.cmove.right'); - if(blueRight)blueRight.click(); - const moved=[...document.querySelectorAll('#pals .fstrip')].map(s=>s.dataset.column); - A(moved.indexOf('blue')>moved.indexOf('gray'),'right arrow moves a color column after its neighbor'); - 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 column chip keeps remove + rename controls'); - const redColumn=redChip&&redChip.closest('.fstrip').dataset.column; - 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.column===redColumn,'a renamed color stays in the same strip'); - PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#0d0b0a','bg2'],['#0d0b0a','bg-alt']];MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';selectedIdx=null;renderPalette(); - const bg2Chip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='bg2'); - A(!!bg2Chip&&bg2Chip.closest('.fstrip').dataset.column==='bg2'&&!!bg2Chip.querySelector('.rm')&&!bg2Chip.querySelector('.lock'),'same-hex bg2 remains a normal removable color column chip'); - if(bg2Chip){bg2Chip.click();document.getElementById('newhexstr').value='#101820';document.getElementById('newname').value='bg2';updateColor();} - A(MAP['bg']==='#0d0b0a','editing same-hex bg2 does not repoint the real bg assignment'); - A(PALETTE.some(p=>p[1]==='bg2'&&p[0]==='#101820'),'editing same-hex bg2 updates only that palette tile'); - PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#c0402a','red','red'],['#3a6ea5','blue','blue'],['#92acc2','blue+1','blue']]; - MAP['kw']='#3a6ea5';selectedIdx=2;clearPalette(); - A(PALETTE.length===2&&PALETTE.every(p=>groundRoleOfEntry(p,{bg:MAP['bg'],fg:MAP['p']})),'clear palette leaves only bg and fg tiles'); - A(!PALETTE.some(p=>p[1]==='red'||p[1]==='blue'||p[1]==='blue+1'),'clear palette removes normal color columns and spans'); - A(MAP['kw']==='#3a6ea5','clear palette leaves existing assignments on gone hexes'); - A(lastGone['blue']==='#3a6ea5'&&lastGone['blue+1']==='#92acc2','clear palette records removed names for recovery'); - A(selectedIdx===null,'clear palette clears selected color'); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);lastGone=saveG;selectedIdx=saveSel;renderPalette(); - document.title='COLUMNTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} -// Count-control gate (open with #counttest): the per-column count regenerates the -// column — 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']='#204060';MAP['p']='#f0fef0'; - PALETTE=[['#204060','bg'],['#f0fef0','fg']]; - setGroundSpan(2); - A(MAP['bg']==='#204060'&&MAP['p']==='#f0fef0','spanning ground keeps bg/fg assignments on endpoints'); - A(PALETTE.some(p=>p[1]==='ground-1')&&PALETTE.some(p=>p[1]==='ground-2'),'spanning ground adds interior ground-N entries'); - A(document.querySelector('#pals .fstrip[data-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles'); - MAP['bg']='#ffffff';MAP['p']='#000000'; - PALETTE=[['#ffffff','bg'],['#bbbbbb','ground-1','ground'],['#777777','ground-2','ground'],['#000000','fg']]; - renderPalette(); - const groundNames=[...document.querySelectorAll('#pals .fstrip[data-column="ground"] .pchip .nm')].map(e=>e.value); - A(groundNames.join('|')==='bg|ground-1|ground-2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|')); - MAP['bg']='#204060';MAP['p']='#f0fef0'; - setGroundSpan(1); - A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps'); - PALETTE=[['#204060','bg'],['#f0fef0','fg']]; - regenColumn('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); - const innerOld=regenColumn('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change - const outerOld=regenColumn('#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(); - setColumnCount('#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=regenColumn('#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); - setColumnCount('#67809c',3); - const want3=regenColumn('#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 column base -// recolors the whole column 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']]; - regenColumn('#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 column=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns[0]; - A(column&&column.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'column 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 with -// stable column ids, and import does not need color-derived column reconstruction. -if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const saveP=PALETTE.slice(),saveM=Object.assign({},MAP); - PALETTE=[['#ffffff','bg','ground'],['#000000','fg','ground'],['#224466','blue','blue'],['#446688','renamed-blue','blue']]; - MAP['bg']='#ffffff';MAP['p']='#000000'; - 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>=3&&typeof e[2]==='string'),'exported palette carries flat [hex,name,columnId] entries'); - A(obj.palette.some(e=>e[1]==='renamed-blue'&&e[2]==='blue'),'renamed color keeps its stable column id through export/import'); - PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM); - 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);} +BROWSER_GATES_J diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js new file mode 100644 index 00000000..8cba692a --- /dev/null +++ b/scripts/theme-studio/browser-gates.js @@ -0,0 +1,428 @@ +// 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); +} +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');} + {LOCKED.clear();buildTable();const b=document.getElementById('syntaxlocktoggle');A(b&&b.textContent==='lock all','syntax toggle starts as lock all');b.click(); + A(syntaxLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','syntax lock-all locks every syntax row and flips label');b.click(); + A(syntaxLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','syntax unlock-all clears every syntax lock and flips label');} + {LOCKED.clear();buildUITable();const b=document.getElementById('uilocktoggle');A(b&&b.textContent==='lock all','ui toggle starts as lock all');b.click(); + A(uiLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','ui lock-all locks every UI row and flips label');b.click(); + A(uiLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','ui unlock-all clears every UI lock and flips label');} + {LOCKED.clear();buildPkgTable();const b=document.getElementById('pkglocktoggle');A(b&&b.textContent==='lock all','pkg toggle starts as lock all');b.click(); + A(pkgLockKeys().every(k=>LOCKED.has(k))&&b.textContent==='unlock all','pkg lock-all locks every current package row and flips label');b.click(); + A(pkgLockKeys().every(k=>!LOCKED.has(k))&&b.textContent==='lock all','pkg unlock-all clears every current package lock and flips label');} + {LOCKED.clear();const app=curApp(),faces=APPS[app].faces.map(r=>r[0]),filter=document.getElementById('pkgfilter'); + if(filter&&faces.length>1){filter.value=faces[0];buildPkgTable();const b=document.getElementById('pkglocktoggle');b.click(); + A(faces.every(face=>LOCKED.has('pkg:'+app+':'+face)),'pkg lock-all covers the whole package even when filtered'); + filter.value='';buildPkgTable();}} + 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&&/background:\s*(?!transparent)/.test(laz.getAttribute('style')||''),'overlay-honors-background-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'); + UIMAP['region'].bold=false;buildUITable(); + const uiBold=[...document.querySelectorAll('#uibody tr')].find(r=>r.dataset.face==='region').querySelector('.sbtn[title="bold"]'); + A(uiBold&&!uiBold.classList.contains('on'),'ui style button starts off when model is false'); + uiBold.click(); + A(uiBold.classList.contains('on')&&UIMAP['region'].bold===true,'ui style button visual state turns on with model'); + uiBold.click(); + A(!uiBold.classList.contains('on')&&UIMAP['region'].bold===false,'ui style button visual state turns off with model'); + const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].bold=false;buildPkgTable(); + const pkgBtn=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] .sbtn[title="bold"]'); + A(pkgBtn()&&!pkgBtn().classList.contains('on'),'pkg style button starts off when model is false'); + pkgBtn().click(); + A(pkgBtn()&&pkgBtn().classList.contains('on')&&PKGMAP[app][face].bold===true,'pkg style button visual state turns on after rebuild'); + 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)); + CATS.forEach(c=>{if(c[0]!=='bg'&&c[0]!=='p')MAP[c[0]]='';}); + 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)'){if(!c){ok=false;notes.push(n);}}; + const box=document.createElement('div'); + box.innerHTML=renderOrgPreview(); + const headline=[...box.querySelectorAll('[data-face="org-headline-todo"]')].find(e=>e.textContent.includes('Heading three')); + A(!!headline&&headline.previousElementSibling&&headline.previousElementSibling.dataset.face==='org-todo','org headline-todo follows a TODO keyword span'); + box.innerHTML=renderFlycheckPreview(); + const delim=[...box.querySelectorAll('[data-face="flycheck-error-delimiter"]')].map(e=>e.textContent).join(''); + const enclosed=[...box.querySelectorAll('[data-face="flycheck-delimited-error"]')].map(e=>e.textContent).join(''); + A(delim==='[]','flycheck delimiters use flycheck-error-delimiter'); + A(enclosed==='err','flycheck enclosed text uses flycheck-delimited-error'); + box.innerHTML=renderErcPreview(); + const own=[...box.querySelectorAll('[data-face="erc-input-face"]')].some(e=>e.textContent.includes('hello everyone')); + const bob=[...box.querySelectorAll('[data-face="erc-default-face"]')].some(e=>e.textContent.includes('hi craig')); + A(own,'erc own sent message uses erc-input-face'); + A(bob,'erc remote message uses erc-default-face'); + document.title='PREVIEWLINKTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='previewlinktest';d.textContent='PREVIEWLINKTEST '+(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);} +// Column-strip gate (open with #columntest): the palette renders as a pinned +// ground column plus structural columns, chips keep their controls, and renaming +// a color leaves it in the same strip because the column id is stable. +if(location.hash==='#columntest'||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),saveG=Object.assign({},lastGone),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].dataset.column==='ground','ground column is pinned first'); + A(strips[0].querySelectorAll('.pchip').length===2,'ground column carries bg + fg endpoints'); + A(!!strips[0].querySelector('.fhead + .fcount + .pchip'),'span control sits between header and tiles for ground'); + A(strips.length>=4,'ground + red + blue + gray columns, got '+strips.length); + const blueHead=strips.find(s=>s.dataset.column==='blue')&&strips.find(s=>s.dataset.column==='blue').querySelector('.ctitle'); + A(!!blueHead,'normal column header has a selectable title'); + if(blueHead)blueHead.click(); + A(selectedIdx!==null&&PALETTE[selectedIdx][1]==='blue'&&document.getElementById('newhexstr').value.toLowerCase()==='#3a6ea5','clicking a column title selects its base color'); + const blueRight=strips.find(s=>s.dataset.column==='blue')&&strips.find(s=>s.dataset.column==='blue').querySelector('.cmove.right'); + if(blueRight)blueRight.click(); + const moved=[...document.querySelectorAll('#pals .fstrip')].map(s=>s.dataset.column); + A(moved.indexOf('blue')>moved.indexOf('gray'),'right arrow moves a color column after its neighbor'); + 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 column chip keeps remove + rename controls'); + const redColumn=redChip&&redChip.closest('.fstrip').dataset.column; + 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.column===redColumn,'a renamed color stays in the same strip'); + PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#0d0b0a','bg2'],['#0d0b0a','bg-alt']];MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';selectedIdx=null;renderPalette(); + const bg2Chip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='bg2'); + A(!!bg2Chip&&bg2Chip.closest('.fstrip').dataset.column==='bg2'&&!!bg2Chip.querySelector('.rm')&&!bg2Chip.querySelector('.lock'),'same-hex bg2 remains a normal removable color column chip'); + if(bg2Chip){bg2Chip.click();document.getElementById('newhexstr').value='#101820';document.getElementById('newname').value='bg2';updateColor();} + A(MAP['bg']==='#0d0b0a','editing same-hex bg2 does not repoint the real bg assignment'); + A(PALETTE.some(p=>p[1]==='bg2'&&p[0]==='#101820'),'editing same-hex bg2 updates only that palette tile'); + PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#c0402a','red','red'],['#3a6ea5','blue','blue'],['#92acc2','blue+1','blue']]; + MAP['kw']='#3a6ea5';selectedIdx=2;clearPalette(); + A(PALETTE.length===2&&PALETTE.every(p=>groundRoleOfEntry(p,{bg:MAP['bg'],fg:MAP['p']})),'clear palette leaves only bg and fg tiles'); + A(!PALETTE.some(p=>p[1]==='red'||p[1]==='blue'||p[1]==='blue+1'),'clear palette removes normal color columns and spans'); + A(MAP['kw']==='#3a6ea5','clear palette leaves existing assignments on gone hexes'); + A(lastGone['blue']==='#3a6ea5'&&lastGone['blue+1']==='#92acc2','clear palette records removed names for recovery'); + A(selectedIdx===null,'clear palette clears selected color'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);lastGone=saveG;selectedIdx=saveSel;renderPalette(); + document.title='COLUMNTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Count-control gate (open with #counttest): the per-column count regenerates the +// column — 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']='#204060';MAP['p']='#f0fef0'; + PALETTE=[['#204060','bg'],['#f0fef0','fg']]; + setGroundSpan(2); + A(MAP['bg']==='#204060'&&MAP['p']==='#f0fef0','spanning ground keeps bg/fg assignments on endpoints'); + A(PALETTE.some(p=>p[1]==='ground-1')&&PALETTE.some(p=>p[1]==='ground-2'),'spanning ground adds interior ground-N entries'); + A(document.querySelector('#pals .fstrip[data-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles'); + MAP['bg']='#ffffff';MAP['p']='#000000'; + PALETTE=[['#ffffff','bg'],['#bbbbbb','ground-1','ground'],['#777777','ground-2','ground'],['#000000','fg']]; + renderPalette(); + const groundNames=[...document.querySelectorAll('#pals .fstrip[data-column="ground"] .pchip .nm')].map(e=>e.value); + A(groundNames.join('|')==='bg|ground-1|ground-2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|')); + MAP['bg']='#204060';MAP['p']='#f0fef0'; + setGroundSpan(1); + A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps'); + PALETTE=[['#204060','bg'],['#f0fef0','fg']]; + regenColumn('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + const innerOld=regenColumn('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change + const outerOld=regenColumn('#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(); + setColumnCount('#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=regenColumn('#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); + setColumnCount('#67809c',3); + const want3=regenColumn('#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 column base +// recolors the whole column 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']]; + regenColumn('#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 column=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns[0]; + A(column&&column.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'column 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 with +// stable column ids, and import does not need color-derived column reconstruction. +if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveL=new Set(LOCKED); + PALETTE=[['#ffffff','bg','ground'],['#000000','fg','ground'],['#224466','blue','blue'],['#446688','renamed-blue','blue']]; + MAP['bg']='#ffffff';MAP['p']='#000000'; + LOCKED=new Set(['kw','ui:region','pkg:'+curApp()+':'+APPS[curApp()].faces[0][0]]); + 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>=3&&typeof e[2]==='string'),'exported palette carries flat [hex,name,columnId] entries'); + A(obj.palette.some(e=>e[1]==='renamed-blue'&&e[2]==='blue'),'renamed color keeps its stable column id through export/import'); + A(obj.locks&&obj.locks.includes('kw')&&obj.locks.includes('ui:region'),'lock state survives export/import'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);LOCKED=saveL; + 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);} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 0b74b985..0b2d4039 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -37,6 +37,12 @@ APP_CORE_BODY=strip_exports(open(os.path.join(HERE,'app-core.js')).read()) # test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in # the page from the colormath core). APP_UTIL_BODY=strip_exports(open(os.path.join(HERE,'app-util.js')).read()) +# Palette panel actions and rendering. This is stateful browser code, split from +# app.js because color-column behavior changes often and benefits from locality. +PALETTE_ACTIONS_BODY=strip_exports(open(os.path.join(HERE,'palette-actions.js')).read()) +# Browser hash gates, split from app.js so the application code is not buried +# under the test harness while still shipping one self-contained HTML file. +BROWSER_GATES_BODY=strip_exports(open(os.path.join(HERE,'browser-gates.js')).read()) ns={} src=open(os.path.join(HERE,'samples.py')).read() exec(src[:src.index('cols=')], ns) @@ -190,6 +196,8 @@ def fill_data(s): return (s.replace("COLORMATH_J",COLORMATH_BODY) .replace("APP_CORE_J",APP_CORE_BODY) .replace("APP_UTIL_J",APP_UTIL_BODY) + .replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY) + .replace("BROWSER_GATES_J",BROWSER_GATES_BODY) .replace("SAMPLES_J",json.dumps(SAMPLES)) .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS)) .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS)) diff --git a/scripts/theme-studio/palette-actions.js b/scripts/theme-studio/palette-actions.js new file mode 100644 index 00000000..ccbf3431 --- /dev/null +++ b/scripts/theme-studio/palette-actions.js @@ -0,0 +1,204 @@ +function clearPalette(){ + normalizePalette(); + const plan=clearPalettePlan(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + plan.removed.forEach(({hex,name})=>{lastGone[name.toLowerCase()]=hex;}); + PALETTE=plan.palette;selectedIdx=null; + renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();renderCode();applyGround(); + notify('cleared palette to bg and fg',false); +} +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;} +function normalizePaletteEntry(entry){ + const hex=entry&&entry[0],name=(entry&&entry[1])||'color'; + return [hex,name,(entry&&entry[2])||columnIdOf(entry)]; +} +function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);} +// The ground column is explicit: bg pins the top endpoint, fg pins the bottom +// endpoint, and generated ground-N steps live between them. +function groundColumnMembers(){ + return groundColumnMembersFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); +} +function groundSpanCount(){return PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step').length;} +function groundSpanControl(){ + const d=document.createElement('div');d.className='fcount'; + d.innerHTML=`span `; + d.querySelector('input').onchange=(e)=>setGroundSpan(Math.max(0,Math.min(8,parseInt(e.target.value,10)||0))); + return d; +} +function setGroundSpan(n){ + const old=PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step'); + const bg=srgb2oklab(MAP['bg']),fg=srgb2oklab(MAP['p']); + const entries=[]; + for(let i=1;i<=n;i++){ + const t=i/(n+1); + const lab={L:bg.L+(fg.L-bg.L)*t,a:bg.a+(fg.a-bg.a)*t,b:bg.b+(fg.b-bg.b)*t}; + entries.push([lrgb2hex(oklab2lrgb(lab.L,lab.a,lab.b)),'ground-'+i,'ground']); + } + for(const [oldHex,oldName] of old){ + const next=entries.find(([,name])=>name===oldName); + if(next&&next[0].toLowerCase()!==oldHex.toLowerCase())repointHex(oldHex,next[0]); + } + for(let i=PALETTE.length-1;i>=0;i--)if(groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']})==='step')PALETTE.splice(i,1); + let at=PALETTE.findIndex(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='bg'); + if(at<0)at=0; else at+=1; + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('set ground span to '+n,false); +} +// 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='
too-similar colors
'; + html+=warnings.map(p=>`
${esc(p.aName+' / '+p.bName)} — \u0394E ${p.dE.toFixed(3)}, hard to distinguish
`).join(''); + if(overflow>0)html+=`
and ${overflow} more
`; + 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 role=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']}); + const locked=(role==='bg'||role==='fg'); + 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?`🔒`:``; + d.innerHTML=`${rm}
${hex}
`; + 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; +} +function paletteIndexByHexName(hex,name){ + for(let i=0;im.hex.toLowerCase()===f.base.toLowerCase())||f.members[0]; + const i=paletteIndexByHexName(baseMember.hex,baseMember.name); + if(i>=0)selectColor(i); +} +function isGroundEntry(entry){ + return !!groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']}); +} +function moveColumn(columnId,dir){ + normalizePalette(); + const columns=sortColumns(columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns); + const pos=columns.findIndex(f=>f.column===columnId); + const next=columns[pos+dir]; + if(pos<0||!next)return; + const moving=[],rest=[]; + PALETTE.forEach(entry=>{ + if(!isGroundEntry(entry)&&columnIdOf(entry)===columnId)moving.push(entry); + else rest.push(entry); + }); + const nextPositions=[]; + rest.forEach((entry,i)=>{if(!isGroundEntry(entry)&&columnIdOf(entry)===next.column)nextPositions.push(i);}); + if(!nextPositions.length)return; + const at=dir<0?nextPositions[0]:nextPositions[nextPositions.length-1]+1; + PALETTE=rest.slice(0,at).concat(moving,rest.slice(at)); + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('moved "'+columnId+'" '+(dir<0?'left':'right'),false); +} +function columnHeader(f,position,count){ + const h=document.createElement('div');h.className='fhead'; + const label=(f.members.find(m=>m.hex.toLowerCase()===f.base.toLowerCase())||{}).name||f.column||f.base; + h.innerHTML=``; + h.querySelector('.ctitle').textContent=label; + h.querySelector('.ctitle').onclick=()=>selectColumnBase(f); + h.querySelector('.left').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,-1);}; + h.querySelector('.right').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,1);}; + return h; +} +// Render the palette as structural color columns: pinned ground column, then +// first-seen palette columns. Grouping uses the stable column id stored on each +// palette entry, so renaming a color never moves it. +function renderPalette(){ + normalizePalette(); + const p=document.getElementById('pals');p.innerHTML=''; + const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); + const {ground,columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const used=new Set(); + const idxOf=(hex,name)=>{for(let i=0;i{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; + if(ground.length){ + const gs=strip(' ground');gs.dataset.column='ground'; + const gh=document.createElement('div');gh.className='fhead';gh.textContent='ground';gs.appendChild(gh); + gs.appendChild(groundSpanControl()); + groundColumnMembers().forEach(m=>{ + const i=idxOf(m.hex,m.name); + if(i>=0)gs.appendChild(paletteChip(i,nearest)); + else{const tc=textOn(m.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=m.hex;sw.title=(m.name||'ground')+' '+m.hex; + sw.innerHTML=`
${m.hex}
`;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. + const ordered=sortColumns(columns); + ordered.forEach((f,pos)=>{ + const s=strip('');s.dataset.column=f.column||f.base; + s.appendChild(columnHeader(f,pos,ordered.length)); + s.appendChild(columnCountControl(f)); + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + }); + renderPaletteWarnings(warnings,overflow); + buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); +} +// The per-column count control under a chromatic strip. Its value is the column's +// current per-side reach; setting N regenerates the column as base ±N. +function columnCountControl(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 ± `; + d.querySelector('input').onchange=(e)=>setColumnCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); + return d; +} +// Regenerate a column 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 regenColumnInPlace(oldHexes,baseHex,baseName,n,columnId){ + const r=regenColumn(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=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); + const col=columnId||columnStem(baseName); + const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset)),col]); + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + for(const [o,nw] of plan.map)repointHex(o,nw); + return plan.removed.length; +} +function setColumnCount(baseHex,n){ + const {columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const column=columns.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); + if(!column)return; + const baseName=(column.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; + const removed=regenColumnInPlace(column.members.map(m=>m.hex),baseHex,baseName,n,column.column); + 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); +} diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 69fe896e..47aaa09f 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, + clearPalettePlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet, } from './app-core.js'; const here = fileURLToPath(new URL('.', import.meta.url)); @@ -76,6 +77,53 @@ test('paletteOptionList: Error — a cur outside palette and ground is surfaced assert.deepEqual(list[1], ['#123456', '(gone) #123456']); }); +test('clearPalettePlan: Normal — removes non-ground colors and records recoverable names', () => { + const plan = clearPalettePlan([ + ['#0d0b0a', 'bg', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ['#67809c', 'blue', 'blue'], + ['#92acc2', 'blue+1', 'blue'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(plan.palette, [['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#67809c', name: 'blue' }, { hex: '#92acc2', name: 'blue+1' }]); +}); + +test('clearPalettePlan: Boundary — synthesizes missing bg and fg endpoints', () => { + const plan = clearPalettePlan([['#67809c', 'blue', 'blue']], { bg: '#000000', fg: '#ffffff' }); + assert.deepEqual(plan.palette, [['#000000', 'bg', 'ground'], ['#ffffff', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#67809c', name: 'blue' }]); +}); + +test('clearPalettePlan: Boundary — same-hex imported colors are not ground endpoints', () => { + const plan = clearPalettePlan([ + ['#0d0b0a', 'bg2', 'bg2'], + ['#0d0b0a', 'bg', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(plan.palette, [['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#0d0b0a', name: 'bg2' }]); +}); + +test('groundColumnMembersFromPalette: Normal — sorts bg, ground-N steps, then fg', () => { + const members = groundColumnMembersFromPalette([ + ['#ffffff', 'bg', 'ground'], + ['#333333', 'ground-2', 'ground'], + ['#bbbbbb', 'ground-1', 'ground'], + ['#000000', 'fg', 'ground'], + ], { bg: '#ffffff', fg: '#000000' }); + assert.deepEqual(members.map(m => m.name), ['bg', 'ground-1', 'ground-2', 'fg']); +}); + +test('lock helpers: Normal — label and toggle operate on the full key set', () => { + const keys = ['a', 'b', 'c']; + assert.equal(areAllLocked(keys, new Set(['a', 'b'])), false); + assert.equal(lockToggleLabel(keys, new Set(['a', 'b'])), 'lock all'); + const locked = toggleLockSet(keys, new Set(['a'])); + assert.deepEqual([...locked].sort(), keys); + assert.equal(lockToggleLabel(keys, locked), 'unlock all'); + assert.deepEqual([...toggleLockSet(keys, locked)].sort(), []); +}); + test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults', () => { const apps = { 'org-mode': { faces: [ ['org-todo', 'todo', { fg: 'blue', bold: true }], @@ -194,3 +242,15 @@ test('inline-integrity: theme-studio.html contains the app-core.js body verbatim const html = readFileSync(here + 'theme-studio.html', 'utf8'); assert.ok(html.includes(body), 'generated page is missing the app-core.js body verbatim'); }); + +test('inline-integrity: theme-studio.html contains palette-actions.js verbatim', () => { + const body = stripExports(readFileSync(here + 'palette-actions.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing palette-actions.js verbatim'); +}); + +test('inline-integrity: theme-studio.html contains browser-gates.js verbatim', () => { + const body = stripExports(readFileSync(here + 'browser-gates.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing browser-gates.js verbatim'); +}); diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index 3b6ad741..e845085e 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -73,6 +73,7 @@ class ColormathInlining(unittest.TestCase): class AssembledPage(unittest.TestCase): PLACEHOLDERS = [ "STYLES_CSS", "APP_JS", "APP_CORE_J", "APP_UTIL_J", + "PALETTE_ACTIONS_J", "BROWSER_GATES_J", "COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J", "UIFACES_J", "UIMAP_J", "APPS_J", "BOLD_J", "MAP_J", ] @@ -95,6 +96,12 @@ class AssembledPage(unittest.TestCase): # app-util.js inlines verbatim after its import line is stripped. self.assertIn(generate.APP_UTIL_BODY, generate.HTML) + def test_page_carries_palette_actions_verbatim(self): + self.assertIn(generate.PALETTE_ACTIONS_BODY, generate.HTML) + + def test_page_carries_browser_gates_verbatim(self): + self.assertIn(generate.BROWSER_GATES_BODY, generate.HTML) + def test_app_util_inlined_body_has_no_import_line(self): # The `import rl` line must be gone, or the page -- cgit v1.2.3