From bd75db78b08c8bffdfe47a69a26883f4f88ad1f9 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 16 Jun 2026 06:27:08 -0500 Subject: feat(theme-studio): prev/next arrows to step the view dropdown I added left and right arrow buttons flanking the view dropdown. They step the selection to the previous or next item and re-render the faces table and preview, so you can walk the list without reopening the dropdown. A pure stepViewIndex helper clamps the index to the option range, no wrap. stepView sets the selection and calls onViewChange. --- scripts/theme-studio/app-core.js | 9 +++++- scripts/theme-studio/app.js | 7 +++++ scripts/theme-studio/browser-gates.js | 18 ++++++++++++ scripts/theme-studio/styles.css | 3 ++ scripts/theme-studio/test-app-core.mjs | 19 ++++++++++++- scripts/theme-studio/theme-studio.html | 37 ++++++++++++++++++++++++- scripts/theme-studio/theme-studio.template.html | 2 +- 7 files changed, 91 insertions(+), 4 deletions(-) (limited to 'scripts') diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index df99a0d37..74b441b96 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -417,6 +417,13 @@ function appViewKeysSorted(apps){ String((apps[b]&&apps[b].label)||b), undefined, {sensitivity:'base'})); } +// The prev/next arrows step the view-dropdown selection by DIR (-1/+1), clamped +// to [0, LEN-1] with no wrap. An empty list (LEN<=0) keeps CUR. +function stepViewIndex(cur,len,dir){ + if(!(len>0)) return cur; + return Math.max(0, Math.min(len-1, cur+dir)); +} + // Which of the six per-face setting boxes (fg, bg, style, inherit, height, box) // differ from the face's seed default, so the table can mark a non-default box. // A non-default height looks identical to the default in the number input, so the @@ -436,4 +443,4 @@ function faceBoxNonDefaults(cur,def){ }; } -export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; +export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, 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/app.js b/scripts/theme-studio/app.js index 07ca06fe1..f2c322aaf 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -528,6 +528,13 @@ function buildViewSel(){const s=document.getElementById('viewsel');if(!s)return; const og=document.createElement('optgroup');og.label='package faces'; for(const app of appViewKeysSorted(APPS))og.appendChild(mk(app,APPS[app].label)); s.appendChild(og);} +// The ‹ › buttons flanking the dropdown step the selection by DIR and re-render +// the view (faces table + preview), so you can walk the list without reopening it. +function stepView(dir){ + const s=document.getElementById('viewsel');if(!s)return; + const i=stepViewIndex(s.selectedIndex,s.options.length,dir); + if(i!==s.selectedIndex){s.selectedIndex=i;onViewChange();} +} function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s.value)||'@code'; const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';}; show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@'); diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index 7c8b05d3f..b03ec6a47 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -730,6 +730,24 @@ if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title)); document.title='CRTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} +// View-nav gate (open with #navtest): the prev/next arrows flanking the view +// dropdown step the selection (clamped, no wrap) and re-render the view. +if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const sel=document.getElementById('viewsel'),prev=document.getElementById('viewprev'),next=document.getElementById('viewnext'); + A(!!prev&&!!next,'nav arrows exist'); + if(sel&&prev&&next){ + const vis=id=>{const e=document.getElementById(id);return !!e&&e.style.display!=='none';}; + sel.selectedIndex=0;onViewChange(); + next.click();A(sel.selectedIndex===1,'next advances the selection'); + prev.click();A(sel.selectedIndex===0,'prev steps back'); + prev.click();A(sel.selectedIndex===0,'prev clamps at the first option'); + sel.selectedIndex=sel.options.length-1;onViewChange(); + next.click();A(sel.selectedIndex===sel.options.length-1,'next clamps at the last option'); + sel.selectedIndex=2;onViewChange(); + A(sel.options[2]&&sel.options[2].value[0]!=='@'&&vis('view-pkg'),'stepping to a package shows the pkg view'); + } + document.title='NAVTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='navtest';d.textContent='NAVTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} // Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of // four radio buttons (none / line / pressed / raised); the color swatch shows // only while a box style is active. diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css index 9c8b5aac9..a90c649ab 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -23,6 +23,9 @@ .stylecluster .sbtn{margin:0} table.leg th:hover{color:#e8bd30} select.chip{appearance:none;border:1px solid #00000060;border-radius:5px;padding:5px 10px;font:bold 14px monospace;width:160px;cursor:pointer} + /* Prev/next arrows flanking the view dropdown: step the selection without reopening it. */ + .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:24px;height:30px;padding:0;margin:0 4px;cursor:pointer;vertical-align:middle} + .viewnav:hover{border-color:#e8bd30} /* Non-default marker: a small gold corner flag on a per-face setting cell whose value differs from the face's default. The size box looks identical default or not, so the flag is the only at-a-glance cue that a value was changed. */ diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 20f3d5734..8f62ae55a 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'; import { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet, - galleryModel, appViewKeysSorted, faceBoxNonDefaults, + galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, } from './app-core.js'; import { planPaletteGenerator, entriesForGeneratedColumn } from './palette-generator-core.js'; import { oklch2hex, deltaE } from './colormath.js'; @@ -878,3 +878,20 @@ test('faceBoxNonDefaults: nullish inputs flag nothing', () => { assert.deepEqual(faceBoxNonDefaults(null, null), { fg: false, bg: false, style: false, inherit: false, height: false, box: false }); }); + +// stepViewIndex: the prev/next arrows step the view-dropdown selection, clamped +// to the option range (no wrap). +test('stepViewIndex: steps forward and back within range', () => { + assert.equal(stepViewIndex(2, 5, 1), 3); + assert.equal(stepViewIndex(2, 5, -1), 1); +}); +test('stepViewIndex: clamps at both ends, no wrap', () => { + assert.equal(stepViewIndex(0, 5, -1), 0); + assert.equal(stepViewIndex(4, 5, 1), 4); +}); +test('stepViewIndex: a single option or empty list stays put', () => { + assert.equal(stepViewIndex(0, 1, 1), 0); + assert.equal(stepViewIndex(0, 1, -1), 0); + assert.equal(stepViewIndex(3, 0, -1), 3); + assert.equal(stepViewIndex(0, 0, 1), 0); +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index c4dd7149d..84c9ea59e 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -25,6 +25,9 @@ .stylecluster .sbtn{margin:0} table.leg th:hover{color:#e8bd30} select.chip{appearance:none;border:1px solid #00000060;border-radius:5px;padding:5px 10px;font:bold 14px monospace;width:160px;cursor:pointer} + /* Prev/next arrows flanking the view dropdown: step the selection without reopening it. */ + .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:24px;height:30px;padding:0;margin:0 4px;cursor:pointer;vertical-align:middle} + .viewnav:hover{border-color:#e8bd30} /* Non-default marker: a small gold corner flag on a per-face setting cell whose value differs from the face's default. The size box looks identical default or not, so the flag is the only at-a-glance cue that a value was changed. */ @@ -223,7 +226,7 @@

assignment

-
+
@@ -927,6 +930,13 @@ function appViewKeysSorted(apps){ String((apps[b]&&apps[b].label)||b), undefined, {sensitivity:'base'})); } +// The prev/next arrows step the view-dropdown selection by DIR (-1/+1), clamped +// to [0, LEN-1] with no wrap. An empty list (LEN<=0) keeps CUR. +function stepViewIndex(cur,len,dir){ + if(!(len>0)) return cur; + return Math.max(0, Math.min(len-1, cur+dir)); +} + // Which of the six per-face setting boxes (fg, bg, style, inherit, height, box) // differ from the face's seed default, so the table can mark a non-default box. // A non-default height looks identical to the default in the number input, so the @@ -2136,6 +2146,13 @@ function buildViewSel(){const s=document.getElementById('viewsel');if(!s)return; const og=document.createElement('optgroup');og.label='package faces'; for(const app of appViewKeysSorted(APPS))og.appendChild(mk(app,APPS[app].label)); s.appendChild(og);} +// The ‹ › buttons flanking the dropdown step the selection by DIR and re-render +// the view (faces table + preview), so you can walk the list without reopening it. +function stepView(dir){ + const s=document.getElementById('viewsel');if(!s)return; + const i=stepViewIndex(s.selectedIndex,s.options.length,dir); + if(i!==s.selectedIndex){s.selectedIndex=i;onViewChange();} +} function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s.value)||'@code'; const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';}; show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@'); @@ -3390,6 +3407,24 @@ if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title)); document.title='CRTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} +// View-nav gate (open with #navtest): the prev/next arrows flanking the view +// dropdown step the selection (clamped, no wrap) and re-render the view. +if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const sel=document.getElementById('viewsel'),prev=document.getElementById('viewprev'),next=document.getElementById('viewnext'); + A(!!prev&&!!next,'nav arrows exist'); + if(sel&&prev&&next){ + const vis=id=>{const e=document.getElementById(id);return !!e&&e.style.display!=='none';}; + sel.selectedIndex=0;onViewChange(); + next.click();A(sel.selectedIndex===1,'next advances the selection'); + prev.click();A(sel.selectedIndex===0,'prev steps back'); + prev.click();A(sel.selectedIndex===0,'prev clamps at the first option'); + sel.selectedIndex=sel.options.length-1;onViewChange(); + next.click();A(sel.selectedIndex===sel.options.length-1,'next clamps at the last option'); + sel.selectedIndex=2;onViewChange(); + A(sel.options[2]&&sel.options[2].value[0]!=='@'&&vis('view-pkg'),'stepping to a package shows the pkg view'); + } + document.title='NAVTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='navtest';d.textContent='NAVTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);} // Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of // four radio buttons (none / line / pressed / raised); the color swatch shows // only while a box style is active. diff --git a/scripts/theme-studio/theme-studio.template.html b/scripts/theme-studio/theme-studio.template.html index 06c3e2bc5..5f41eb66d 100644 --- a/scripts/theme-studio/theme-studio.template.html +++ b/scripts/theme-studio/theme-studio.template.html @@ -58,7 +58,7 @@ STYLES_CSS

assignment

-
+
-- cgit v1.2.3