From 1b4e5f88353180cf999412faa2be9e0326b78361 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 15 Jun 2026 19:54:37 -0500 Subject: feat(theme-studio): show view-area > element usages on palette tile hover I added paletteUsages, which enumerates every place a color is assigned, grouped by view area (the view dropdown's names: color/code assignments, ui faces, each package app) and the element within it. renderPalette builds the per-area scopes once and appends the list to each used tile's hover title, under the existing name/hex/nearest-deltaE line. Node tests and a #usagetest gate cover it. --- scripts/theme-studio/app-core.js | 19 ++++++++++++- scripts/theme-studio/browser-gates.js | 16 +++++++++++ scripts/theme-studio/palette-actions.js | 14 +++++++--- scripts/theme-studio/test-columns.mjs | 18 ++++++++++++- scripts/theme-studio/theme-studio.html | 47 ++++++++++++++++++++++++++++++--- 5 files changed, 106 insertions(+), 8 deletions(-) (limited to 'scripts') diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index d21254e40..96feb89c2 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -255,6 +255,23 @@ function usedPaletteHexes(palette,syntax,uimap,pkgmap,ground){ for(const app in (pkgmap||{}))for(const face in pkgmap[app])addFace(pkgmap[app][face]); return used; } +// Enumerate where a palette color is used, as "area > element" strings. scopes +// is [{area, faces:{element: faceObj}}] -- one scope per view area (color/code, +// ui faces, each package app), element keyed by its display label. A face counts +// if any of fg / bg / box-color resolves (by hex or palette name) to the target. +function paletteUsages(hex,scopes,palette){ + const target=(hex||'').toLowerCase(); + if(!target)return []; + const out=[]; + for(const {area,faces} of (scopes||[])){ + for(const element in (faces||{})){ + const f=faces[element];if(!f)continue; + const vals=[f.fg,f.bg,f.box&&f.box.color]; + if(vals.some(v=>{const h=nameToHex(v,palette);return h&&h.toLowerCase()===target;}))out.push(area+' > '+element); + } + } + return out; +} function columnsFromPalette(palette,ground){ const bg=ground&&ground.bg,fg=ground&&ground.fg; const groundStrip=[]; @@ -367,4 +384,4 @@ function spanNeighborHex(cur,palette,ground,dir){ return null; } -export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; +export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index 2146c2451..9c49798e1 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -767,3 +767,19 @@ if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);syncSyntaxFromCache();buildUITable(); document.title='GONETEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='gonetest';d.textContent='GONETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} +// Tile-usage-hover gate (open with #usagetest): a tile's title lists the +// "view area > element" pairings that use its color, under the name/hex line. +if(location.hash==='#usagetest'){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)); + setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0'); + PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']]; + const f0=UI_FACES[0][0],f0label=UI_FACES[0][1]||f0; + for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false}; + UIMAP[f0]={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette(); + const blueChip=document.querySelector('#pals .fstrip[data-column="blue"] .pchip'); + A(blueChip&&blueChip.title.includes('ui faces > '+f0label),'hover-title-lists-ui-face-usage'); + A(blueChip&&blueChip.title.split('\n').length>1,'usage-list-on-its-own-line-under-current-info'); + 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);syncSyntaxFromCache();renderPalette(); + document.title='USAGETEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='usagetest';d.textContent='USAGETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} diff --git a/scripts/theme-studio/palette-actions.js b/scripts/theme-studio/palette-actions.js index dd060dc8c..0f49225a8 100644 --- a/scripts/theme-studio/palette-actions.js +++ b/scripts/theme-studio/palette-actions.js @@ -87,7 +87,7 @@ function renderPaletteWarnings(warnings,overflow){ } // 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,used){ +function paletteChip(i,nearest,used,scopes){ 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'); @@ -95,6 +95,7 @@ function paletteChip(i,nearest,used){ d.dataset.paletteIndex=String(i); d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); if(used&&!used.has(hex.toLowerCase())){d.classList.add('unused');d.title+=' — not used in the theme';} + else if(scopes){const u=paletteUsages(hex,scopes,PALETTE);if(u.length)d.title+='\n'+u.join('\n');} const rm=locked?`🔒`:``; d.innerHTML=`${rm}
${hex}
`; if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();rememberGone(hex,name);PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;refreshPaletteState({code:false,ground:false});}; @@ -185,6 +186,13 @@ function renderPalette(){ const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); const {ground,columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); const usedHexes=usedPaletteHexes(PALETTE,SYNTAX,UIMAP,PKGMAP,{bg:MAP['bg'],fg:MAP['p']}); + // Per-view-area scopes for the hover "view area > element" usage list. Area + // names match the view dropdown; elements use each tier's display label. + const usageScopes=[ + {area:'color/code assignments',faces:Object.fromEntries(CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>[c[1]||c[0],syntaxFace(c[0])]))}, + {area:'ui faces',faces:Object.fromEntries(UI_FACES.map(u=>[u[1]||u[0],UIMAP[u[0]]]))}, + ...Object.keys(APPS).map(app=>({area:APPS[app].label,faces:Object.fromEntries(APPS[app].faces.map(r=>[r[1]||r[0],PKGMAP[app][r[0]]]))})) + ]; 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;}; @@ -194,7 +202,7 @@ function renderPalette(){ gs.appendChild(groundSpanControl()); (paletteShowFull?groundColumnMembers():groundColumnMembers().filter(m=>!/^ground[+-]\d+$/i.test(m.name||''))).forEach(m=>{ const i=idxOf(m.hex,m.name); - if(i>=0)gs.appendChild(paletteChip(i,nearest,usedHexes)); + if(i>=0)gs.appendChild(paletteChip(i,nearest,usedHexes,usageScopes)); 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);} }); @@ -206,7 +214,7 @@ function renderPalette(){ const s=strip('');s.dataset.column=f.column||f.base; s.appendChild(columnHeader(f,pos,ordered.length)); s.appendChild(columnCountControl(f)); - (paletteShowFull?f.members:f.members.filter(m=>m.hex.toLowerCase()===f.base.toLowerCase())).forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest,usedHexes));}); + (paletteShowFull?f.members:f.members.filter(m=>m.hex.toLowerCase()===f.base.toLowerCase())).forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest,usedHexes,usageScopes));}); if(f.members.every(m=>!usedHexes.has(m.hex.toLowerCase())))s.classList.add('unused-col'); }); renderPaletteWarnings(warnings,overflow); diff --git a/scripts/theme-studio/test-columns.mjs b/scripts/theme-studio/test-columns.mjs index ba949cea4..a63e5e0e0 100644 --- a/scripts/theme-studio/test-columns.mjs +++ b/scripts/theme-studio/test-columns.mjs @@ -5,7 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { columnsFromPalette, usedPaletteHexes, regenColumn, groundRoleOfEntry, rankByLightness, stepRepointPlan, sortColumns } from './app-core.js'; +import { columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, groundRoleOfEntry, rankByLightness, stepRepointPlan, sortColumns } from './app-core.js'; const columnOf = (columns, name) => columns.find(f => f.members.some(m => m.name === name)); @@ -237,3 +237,19 @@ test('usedPaletteHexes: Boundary - empty maps leave only the ground endpoints', const used = usedPaletteHexes([['#101010','bg','ground'],['#f0f0f0','fg','ground']], {}, {}, {}, { bg: '#101010', fg: '#f0f0f0' }); assert.deepEqual([...used].sort(), ['#101010', '#f0f0f0']); }); + +// --- paletteUsages (hover "view area > element") ---------------------------- +test('paletteUsages: Normal - lists area > element for every assignment of the color', () => { + const palette = [['#67809c','blue','blue']]; + const scopes = [ + { area: 'color/code assignments', faces: { keyword: { fg: '#67809c' }, string: { fg: '#aabbcc' } } }, + { area: 'ui faces', faces: { region: { bg: 'blue' } } }, + { area: 'magit', faces: { branch: { fg: '#999999', box: { color: '#67809c' } } } }, + ]; + assert.deepEqual(paletteUsages('#67809c', scopes, palette).sort(), + ['color/code assignments > keyword', 'magit > branch', 'ui faces > region'].sort()); +}); + +test('paletteUsages: Boundary - a color used nowhere returns an empty list', () => { + assert.deepEqual(paletteUsages('#123456', [{ area: 'ui faces', faces: { region: { bg: '#000000' } } }], []), []); +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 00ddb8adc..6cbe85f5e 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -759,6 +759,23 @@ function usedPaletteHexes(palette,syntax,uimap,pkgmap,ground){ for(const app in (pkgmap||{}))for(const face in pkgmap[app])addFace(pkgmap[app][face]); return used; } +// Enumerate where a palette color is used, as "area > element" strings. scopes +// is [{area, faces:{element: faceObj}}] -- one scope per view area (color/code, +// ui faces, each package app), element keyed by its display label. A face counts +// if any of fg / bg / box-color resolves (by hex or palette name) to the target. +function paletteUsages(hex,scopes,palette){ + const target=(hex||'').toLowerCase(); + if(!target)return []; + const out=[]; + for(const {area,faces} of (scopes||[])){ + for(const element in (faces||{})){ + const f=faces[element];if(!f)continue; + const vals=[f.fg,f.bg,f.box&&f.box.color]; + if(vals.some(v=>{const h=nameToHex(v,palette);return h&&h.toLowerCase()===target;}))out.push(area+' > '+element); + } + } + return out; +} function columnsFromPalette(palette,ground){ const bg=ground&&ground.bg,fg=ground&&ground.fg; const groundStrip=[]; @@ -1567,7 +1584,7 @@ function renderPaletteWarnings(warnings,overflow){ } // 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,used){ +function paletteChip(i,nearest,used,scopes){ 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'); @@ -1575,6 +1592,7 @@ function paletteChip(i,nearest,used){ d.dataset.paletteIndex=String(i); d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); if(used&&!used.has(hex.toLowerCase())){d.classList.add('unused');d.title+=' — not used in the theme';} + else if(scopes){const u=paletteUsages(hex,scopes,PALETTE);if(u.length)d.title+='\n'+u.join('\n');} const rm=locked?`🔒`:``; d.innerHTML=`${rm}
${hex}
`; if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();rememberGone(hex,name);PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;refreshPaletteState({code:false,ground:false});}; @@ -1665,6 +1683,13 @@ function renderPalette(){ const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); const {ground,columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); const usedHexes=usedPaletteHexes(PALETTE,SYNTAX,UIMAP,PKGMAP,{bg:MAP['bg'],fg:MAP['p']}); + // Per-view-area scopes for the hover "view area > element" usage list. Area + // names match the view dropdown; elements use each tier's display label. + const usageScopes=[ + {area:'color/code assignments',faces:Object.fromEntries(CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>[c[1]||c[0],syntaxFace(c[0])]))}, + {area:'ui faces',faces:Object.fromEntries(UI_FACES.map(u=>[u[1]||u[0],UIMAP[u[0]]]))}, + ...Object.keys(APPS).map(app=>({area:APPS[app].label,faces:Object.fromEntries(APPS[app].faces.map(r=>[r[1]||r[0],PKGMAP[app][r[0]]]))})) + ]; 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;}; @@ -1674,7 +1699,7 @@ function renderPalette(){ gs.appendChild(groundSpanControl()); (paletteShowFull?groundColumnMembers():groundColumnMembers().filter(m=>!/^ground[+-]\d+$/i.test(m.name||''))).forEach(m=>{ const i=idxOf(m.hex,m.name); - if(i>=0)gs.appendChild(paletteChip(i,nearest,usedHexes)); + if(i>=0)gs.appendChild(paletteChip(i,nearest,usedHexes,usageScopes)); 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);} }); @@ -1686,7 +1711,7 @@ function renderPalette(){ const s=strip('');s.dataset.column=f.column||f.base; s.appendChild(columnHeader(f,pos,ordered.length)); s.appendChild(columnCountControl(f)); - (paletteShowFull?f.members:f.members.filter(m=>m.hex.toLowerCase()===f.base.toLowerCase())).forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest,usedHexes));}); + (paletteShowFull?f.members:f.members.filter(m=>m.hex.toLowerCase()===f.base.toLowerCase())).forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest,usedHexes,usageScopes));}); if(f.members.every(m=>!usedHexes.has(m.hex.toLowerCase())))s.classList.add('unused-col'); }); renderPaletteWarnings(warnings,overflow); @@ -3317,4 +3342,20 @@ if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);syncSyntaxFromCache();buildUITable(); document.title='GONETEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='gonetest';d.textContent='GONETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} +// Tile-usage-hover gate (open with #usagetest): a tile's title lists the +// "view area > element" pairings that use its color, under the name/hex line. +if(location.hash==='#usagetest'){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)); + setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0'); + PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']]; + const f0=UI_FACES[0][0],f0label=UI_FACES[0][1]||f0; + for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false}; + UIMAP[f0]={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette(); + const blueChip=document.querySelector('#pals .fstrip[data-column="blue"] .pchip'); + A(blueChip&&blueChip.title.includes('ui faces > '+f0label),'hover-title-lists-ui-face-usage'); + A(blueChip&&blueChip.title.split('\n').length>1,'usage-list-on-its-own-line-under-current-info'); + 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);syncSyntaxFromCache();renderPalette(); + document.title='USAGETEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='usagetest';d.textContent='USAGETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} -- cgit v1.2.3