diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 73cff73d98..8cbf06650f 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -312,8 +312,8 @@ body:not(.preview-fullscreen) #artifact-shell[data-resized]{width:var(--resized- #activity-caption::after{width:2px;height:2px;left:-15px;top:72%} #activity-caption[hidden]{display:none} -#preview-waveform{position:fixed;left:14px;width:calc(100% - 28px);height:32px;z-index:4;pointer-events:none;opacity:0;transition:opacity .35s;overflow:hidden} -#preview-waveform.sounding{opacity:.45} +#preview-waveform{position:fixed;left:14px;width:calc(100% - 28px);height:48px;z-index:4;pointer-events:none;opacity:0;transition:opacity .35s;overflow:hidden} +#preview-waveform.sounding{opacity:.65} #preview-waveform path{fill:none;stroke:var(--aesel-accent,#ff78b2);stroke-width:1;stroke-linejoin:round;stroke-linecap:round} body.preview-fullscreen #preview-waveform{display:none} @media(prefers-reduced-motion:reduce){#preview-waveform{transition:none}} @@ -332,3 +332,18 @@ body.preview-fullscreen #preview-waveform{display:none} #token-grass{position:fixed;z-index:3;pointer-events:none} #token-grass[hidden],body.preview-fullscreen #token-grass{display:none} + +#provider-menu .preview-audio-controls{display:flex;align-items:center;gap:8px;margin-top:5px} +#provider-menu .preview-audio-controls input{min-width:0;flex:1;accent-color:var(--aesel-accent,#ff78b2)} +#provider-menu .preview-audio-controls output{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums} +#provider-menu .preview-mute{width:auto;flex:none;padding:6px 8px;border:1px solid color-mix(in srgb,currentColor 25%,transparent)} + +#provider-menu .provider-model-row{display:flex;gap:10px;align-items:start} +#provider-menu .provider-model-row>.settings-field{flex:1;min-width:0} +#provider-menu .provider-picker{position:relative} +#provider-menu .provider-toggle{display:flex;align-items:center;gap:6px;margin-top:5px;padding:8px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:6px;font:15px Helvetica,sans-serif;min-height:36px} +#provider-menu .provider-arrow{margin-left:auto} +#provider-menu .provider-options{position:absolute;top:100%;left:0;min-width:100%;z-index:10;padding:4px;box-sizing:border-box;border:1px solid color-mix(in srgb,currentColor 30%,transparent);border-radius:6px;background:var(--aesel-background,#24152a);box-shadow:0 4px 14px #0006} +#provider-menu .provider-options[hidden]{display:none} +#provider-menu .provider-options button{display:flex;align-items:center;gap:8px;white-space:nowrap} +#provider-menu .provider-options [aria-selected=true]{background:color-mix(in srgb,currentColor 12%,transparent)} diff --git a/easel/desktop/index.html b/easel/desktop/index.html index 39c35623f5..053ae97328 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -5,4 +5,4 @@
- + diff --git a/easel/desktop/preview-audio.js b/easel/desktop/preview-audio.js new file mode 100644 index 0000000000..878ed82c56 --- /dev/null +++ b/easel/desktop/preview-audio.js @@ -0,0 +1,26 @@ +window.installPreviewAudio=preview=>{ + let saved={};try{saved=JSON.parse(localStorage.getItem('aesel-preview-audio')||'{}');}catch{} + const state={volume:Number.isFinite(saved.volume)?Math.max(0,Math.min(1,saved.volume)):1,muted:saved.muted===true}; + let ready=false,timer=0,generation=0; + async function apply(attempt=0){ + clearTimeout(timer);const current=generation; + try{ + preview.setAudioMuted(state.muted||state.volume===0); + if(!ready)return; + const applied=await preview.executeJavaScript(`(()=>{if(typeof window.AC?.setMasterVolume!=='function')return false;window.AC.setMasterVolume(${state.volume});return true})()`); + if(!applied&¤t===generation&&attempt<50)timer=setTimeout(()=>apply(attempt+1),200); + }catch{} + } + const controls={get volume(){return state.volume},get muted(){return state.muted},set(value){ + if(Number.isFinite(value.volume))state.volume=Math.max(0,Math.min(1,value.volume)); + if(typeof value.muted==='boolean')state.muted=value.muted; + try{localStorage.setItem('aesel-preview-audio',JSON.stringify(state));}catch{} + generation++;void apply(); + }}; + preview.addEventListener('did-start-loading',()=>{ready=false;generation++;clearTimeout(timer);}); + const start=()=>{if(ready)return;ready=true;generation++;void apply();}; + preview.addEventListener('dom-ready',start);preview.addEventListener('did-stop-loading',start); + queueMicrotask(()=>{try{if(preview.getWebContentsId()&&!preview.isLoading())start();}catch{}}); + window.addEventListener('beforeunload',()=>clearTimeout(timer),{once:true}); + return controls; +}; diff --git a/easel/desktop/preview-waveform.js b/easel/desktop/preview-waveform.js index c22c7d4e13..f97fcba143 100644 --- a/easel/desktop/preview-waveform.js +++ b/easel/desktop/preview-waveform.js @@ -48,11 +48,8 @@ window.installPreviewWaveform = (preview) => { !!lastSound && performance.now() - lastSound < 600, ); if (!line.classList.contains("sounding")) return; - const title = document.getElementById("qr-label").getBoundingClientRect(); - const top = document - .getElementById("artifact-shell") - .getBoundingClientRect().top; - line.style.top = `${Math.max(0, (title.height ? title.top + title.height / 2 : top + 8) - 16)}px`; + const box = document.getElementById("artifact-shell").getBoundingClientRect(); + line.style.top = `${Math.max(0, box.top + box.height / 2 - 24)}px`; path.setAttribute( "d", motion.matches || !active @@ -79,11 +76,16 @@ window.installPreviewWaveform = (preview) => { }; preview.addEventListener("did-start-loading", stop); preview.addEventListener("destroyed", stop); - preview.addEventListener("dom-ready", () => { - stop(); + const start = () => { + if (ready) return; ready = true; void poll(); - }); + }; + preview.addEventListener("dom-ready", start); + // Loading can restart without a fresh DOM (including same-document loads). + preview.addEventListener("did-stop-loading", start); + // The guest may already be ready when this optional decoration is installed. + queueMicrotask(() => { try { if (preview.getWebContentsId() && !preview.isLoading()) start(); } catch {} }); document.addEventListener("visibilitychange", () => { if (document.hidden) clear(); }); diff --git a/easel/desktop/provider-picker.js b/easel/desktop/provider-picker.js new file mode 100644 index 0000000000..5d675839d1 --- /dev/null +++ b/easel/desktop/provider-picker.js @@ -0,0 +1,17 @@ +window.createProviderPicker=({backend,busy,onSelect})=>{ + const root=document.createElement('div');root.className='settings-field provider-picker';root.append('Provider'); + const toggle=document.createElement('button');toggle.type='button';toggle.className='provider-toggle';toggle.setAttribute('aria-label','Provider');toggle.setAttribute('aria-haspopup','listbox');toggle.setAttribute('aria-expanded','false');toggle.setAttribute('aria-controls','provider-options');toggle.disabled=busy; + const list=document.createElement('div');list.id='provider-options';list.className='provider-options';list.setAttribute('role','listbox');list.setAttribute('aria-label','Provider');list.hidden=true; + const providers=['AC','Claude','Codex'],ids=['ac','claude','codex']; + const icon=id=>{const img=new Image();img.src=`assets/provider-${id}.svg`;img.alt='';img.className='provider-mark';return img;}; + const current=Math.max(0,ids.indexOf(backend));toggle.append(icon(ids[current]),providers[current]); + const arrow=document.createElement('span');arrow.className='provider-arrow';arrow.textContent='⌄';arrow.setAttribute('aria-hidden','true');toggle.append(arrow); + const close=()=>{list.hidden=true;toggle.setAttribute('aria-expanded','false');};root.closePicker=close; + const open=()=>{list.hidden=false;toggle.setAttribute('aria-expanded','true');list.children[current].focus();}; + providers.forEach((name,index)=>{const option=document.createElement('button');option.type='button';option.setAttribute('role','option');option.setAttribute('aria-selected',String(index===current));option.tabIndex=-1;option.append(icon(ids[index]),name);option.addEventListener('click',()=>{close();toggle.focus();onSelect(index);});list.append(option);}); + toggle.addEventListener('click',()=>list.hidden?open():close()); + toggle.addEventListener('keydown',event=>{if(['ArrowDown','ArrowUp'].includes(event.key)){event.preventDefault();open();}}); + list.addEventListener('keydown',event=>{let index=[...list.children].indexOf(document.activeElement);if(event.key==='ArrowDown')index=(index+1)%3;else if(event.key==='ArrowUp')index=(index+2)%3;else if(event.key==='Home')index=0;else if(event.key==='End')index=2;else return;event.preventDefault();list.children[index].focus();}); + root.addEventListener('focusout',()=>queueMicrotask(()=>{if(!root.contains(document.activeElement))close();})); + root.append(toggle,list);return root; +}; diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 933964d14e..393200f5a6 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -458,6 +458,7 @@ window.addEventListener('blur',()=>forwardGamepad([])); const artifact = document.getElementById('artifact'); window.installPreviewShutter(artifact); window.installPreviewWaveform(preview); +window.previewAudio=window.installPreviewAudio(preview); document.getElementById('version').setAttribute('aria-label','Piece version'); const previewViewport = document.getElementById('preview-viewport'); // The guest keeps a display-aspect viewport, 128 pixels high. Only its composited surface @@ -638,27 +639,19 @@ boot().catch(error => { document.body.dataset.notebookReady='true';document.getE function rebuildMenu(){ const nextKey=JSON.stringify([provider,credits,buildInfo]);if(nextKey===menuKey)return; // Live activity must not tear down an open native dropdown mid-selection. - if(menu.open&&menu.contains(document.activeElement)&&document.activeElement.tagName==='SELECT'&&!provider?.busy)return; + if(menu.open&&!provider?.busy&&(menu.querySelector('.provider-toggle[aria-expanded="true"]')||(menu.contains(document.activeElement)&&document.activeElement.matches('select,input[type=range]'))))return; menuKey=nextKey; const focused=document.activeElement?.textContent; const scroll=menu.scrollTop;const previewHost=menu.querySelector('.history-preview-host');const historyOpen=menu.querySelector('details')?.open??true; menu.replaceChildren();const n=balance(); const header=document.createElement('header');const title=document.createElement('h2');title.textContent='Settings'; const dismiss=document.createElement('button');dismiss.type='button';dismiss.textContent='×';dismiss.setAttribute('aria-label','Close settings');dismiss.addEventListener('click',close);header.append(title,dismiss);menu.append(header); - if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;status.title=buildInfo.revision?`Source commit ${buildInfo.revision}`:'';const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,(buildInfo.tree||buildInfo.revision)?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);item('Check for updates',()=>window.aesel.checkBuildUpdates());} - const current=document.createElement('p');current.textContent=[provider?.backend==='ac'?'Braincells · automatic model':provider?.backend==='codex'?'Codex':'Claude',provider?.backend==='ac'?'':provider?.model].filter(Boolean).join(' · ');menu.append(current); - - const providerLabel=document.createElement('label');providerLabel.textContent='Provider';providerLabel.className='settings-field'; - const providerSelect=document.createElement('select');providerSelect.setAttribute('aria-label','Provider');providerSelect.disabled=!!provider?.busy; - for(const [index,id,name] of [[0,'ac','AC · Braincells'],[1,'claude','Claude'],[2,'codex','Codex']]){ - const option=document.createElement('option');option.value=String(index);option.textContent=name;option.selected=provider?.backend===id;providerSelect.append(option); - } - providerSelect.addEventListener('change',()=>{ - const index=Number(providerSelect.value),id=['ac','claude','codex'][index]; - if(provider?.busy||!id||provider?.backend===id)return; + if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;status.title=buildInfo.revision?`Source commit ${buildInfo.revision}`:'';const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,(buildInfo.tree||buildInfo.revision)?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);} + const choices=document.createElement('div');choices.className='provider-model-row';menu.append(choices); + choices.append(window.createProviderPicker({backend:provider?.backend,busy:!!provider?.busy,onSelect:index=>{ + if(provider?.busy||provider?.backend===['ac','claude','codex'][index])return; close();window.aesel.input(`\x1b[99;${index}~`); - }); - providerLabel.append(providerSelect);menu.append(providerLabel); + }})); if(provider?.backend!=='ac'){ const modelLabel=document.createElement('label');modelLabel.textContent='Model';modelLabel.className='settings-field'; const modelSelect=document.createElement('select');modelSelect.setAttribute('aria-label','Model');modelSelect.disabled=!!provider?.busy||!provider?.models?.length; @@ -669,9 +662,18 @@ boot().catch(error => { document.body.dataset.notebookReady='true';document.getE if(![...(provider?.models||[])].some(model=>model.id===(provider.selectedModel??provider.model)))modelSelect.selectedIndex=0; const backendIndex=['ac','claude','codex'].indexOf(provider?.backend); modelSelect.addEventListener('change',()=>{if(provider?.busy)return;window.aesel.input(`\x1b[99;4;${backendIndex};${modelSelect.value}~`);}); - modelLabel.append(modelSelect);menu.append(modelLabel); + modelLabel.append(modelSelect);choices.append(modelLabel); } + const audioLabel=document.createElement('label');audioLabel.className='settings-field';audioLabel.textContent='Preview volume'; + const audioRow=document.createElement('div');audioRow.className='preview-audio-controls'; + const volume=document.createElement('input');volume.type='range';volume.min='0';volume.max='100';volume.step='1';volume.value=String(Math.round((window.previewAudio?.volume??1)*100));volume.setAttribute('aria-label','Preview volume'); + const level=document.createElement('output');level.textContent=volume.value+'%'; + const mute=document.createElement('button');mute.type='button';mute.className='preview-mute'; + const updateMute=()=>{const muted=window.previewAudio?.muted;mute.textContent=muted?'Unmute':'Mute';mute.setAttribute('aria-pressed',String(!!muted));};updateMute(); + volume.addEventListener('input',()=>{level.textContent=volume.value+'%';window.previewAudio?.set({volume:Number(volume.value)/100});}); + mute.addEventListener('click',()=>{window.previewAudio?.set({muted:!window.previewAudio.muted});updateMute();}); + audioRow.append(volume,level,mute);audioLabel.append(audioRow);menu.append(audioLabel); const stateLine=document.createElement('p');stateLine.className='inference-status';stateLine.textContent=[provider?.mode==='local'?'Local inference':'Remote inference',provider?.status,provider?.activity].filter(Boolean).join(' · ');menu.append(stateLine); if(provider?.notice){const notice=document.createElement('p');notice.className='session-notice';notice.textContent=provider.notice;menu.append(notice);} if(provider?.backend==='ac'){const count=document.createElement('p');count.className='braincell-balance';const icon=new Image();icon.src='assets/braincell.svg';icon.alt='';icon.className='provider-mark';const dollars=credits?.dollars;const usd=value=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(value); @@ -700,6 +702,7 @@ boot().catch(error => { document.body.dataset.notebookReady='true';document.getE if(menu.open)[...menu.querySelectorAll('button')].find(button=>button.textContent===focused)?.focus({preventScroll:true}); menu.scrollTop=scroll; } + menu.addEventListener('pointerdown',event=>{const picker=menu.querySelector('.provider-picker');if(picker&&!picker.contains(event.target))picker.closePicker();}); menu.addEventListener('focusout',()=>queueMicrotask(rebuildMenu)); async function render(){ const n=balance();const hosted=provider?.backend==='ac'; @@ -720,7 +723,7 @@ boot().catch(error => { document.body.dataset.notebookReady='true';document.getE window.aesel.onCredits(value=>{credits=value;render();}); footer.addEventListener('click',()=>{if(menu.hidden){menu.hidden=false;menu.showModal();window.aesel.input('\x1b[99;5~');footer.setAttribute('aria-expanded','true');menu.querySelector('button:not(:disabled)')?.focus();}else close();}); document.addEventListener('pointerdown',event=>{if(!footer.contains(event.target)&&!menu.contains(event.target))close();}); - document.addEventListener('keydown',event=>{if(event.key==='Escape'&&!menu.hidden){close();footer.focus();event.preventDefault();event.stopPropagation();}},true); + document.addEventListener('keydown',event=>{if(event.key==='Escape'&&!menu.hidden){const picker=menu.querySelector('.provider-toggle[aria-expanded="true"]');if(picker){picker.closest('.provider-picker').closePicker();picker.focus();event.preventDefault();event.stopPropagation();return;}close();footer.focus();event.preventDefault();event.stopPropagation();}},true); render(); })(); diff --git a/easel/test/notebook-layout.electron.cjs b/easel/test/notebook-layout.electron.cjs index b72a1636a0..3a8be496c5 100644 --- a/easel/test/notebook-layout.electron.cjs +++ b/easel/test/notebook-layout.electron.cjs @@ -163,13 +163,15 @@ const assert = require("node:assert/strict"); ), "Remote inference · thinking · Reading the piece", ); - assert.deepEqual(await win.webContents.executeJavaScript(`Array.from(document.querySelector('select[aria-label="Provider"]').options,o=>o.textContent)`),['AC · Braincells','Claude','Codex']); - await win.webContents.executeJavaScript(`window.providerDropdown=document.querySelector('select[aria-label="Provider"]');providerDropdown.focus();window.updateProviderFooter({backend:'ac',model:'Luna',status:'ready',mode:'remote',activity:'A new status',models:[],versions:[]})`); - assert.equal(await win.webContents.executeJavaScript(`document.querySelector('select[aria-label="Provider"]')===window.providerDropdown`),true,'Status updates preserve the open dropdown'); - await win.webContents.executeJavaScript(`providerDropdown.value='2';providerDropdown.dispatchEvent(new Event('change'))`); + assert.deepEqual(await win.webContents.executeJavaScript(`Array.from(document.querySelectorAll('#provider-options [role=option]'),o=>o.textContent)`),['AC','Claude','Codex']); + await win.webContents.executeJavaScript(`window.providerDropdown=document.querySelector('button[aria-label="Provider"]');providerDropdown.click();window.updateProviderFooter({backend:'ac',model:'Luna',status:'ready',mode:'remote',activity:'A new status',models:[],versions:[]})`); + assert.equal(await win.webContents.executeJavaScript(`document.querySelector('button[aria-label="Provider"]')===window.providerDropdown`),true,'Status updates preserve the open dropdown'); + await win.webContents.executeJavaScript(`document.querySelectorAll('#provider-options [role=option]')[2].click()`); await delay(40);assert(inputs.includes('\x1b[99;2~')); await win.webContents.executeJavaScript(`window.updateProviderFooter({backend:'claude',model:'sonnet',selectedModel:'sonnet',models:[{id:'sonnet',label:'Sonnet'},{id:'opus',label:'Opus'}],versions:[]});document.getElementById('credit-label').click();const m=document.querySelector('select[aria-label="Model"]');m.value='1';m.dispatchEvent(new Event('change'))`); await delay(40);assert(inputs.includes('\x1b[99;4;1;1~')); + assert.equal(await win.webContents.executeJavaScript(`(()=>{const a=document.querySelector('.provider-toggle').getBoundingClientRect(),b=document.querySelector('select[aria-label="Model"]').getBoundingClientRect();return Math.abs(a.top-b.top)<2&&b.left>a.right})()`),true); + assert.equal(await win.webContents.executeJavaScript(`document.querySelectorAll('#provider-options [role=option] img').length`),3); await win.webContents.executeJavaScript( `document.querySelector('#provider-menu header button').click()`, ); diff --git a/easel/test/preview-waveform.electron.cjs b/easel/test/preview-waveform.electron.cjs index aef652ef30..ab7fa7d0ff 100644 --- a/easel/test/preview-waveform.electron.cjs +++ b/easel/test/preview-waveform.electron.cjs @@ -67,8 +67,9 @@ let win; ) .replace("export function", "function"); await guest.executeJavaScript( - `${meter}\nwindow.ctx=new AudioContext();window.gain=ctx.createGain();gain.gain.value=.12;window.quiet=ctx.createGain();quiet.gain.value=0;gain.connect(quiet).connect(ctx.destination);window.osc=ctx.createOscillator();osc.frequency.value=220;osc.connect(gain);osc.start();window.AC={readOutputWaveform:createOutputWaveform(()=>[gain])};ctx.resume();`, + `${meter}\nwindow.ctx=new AudioContext();window.gain=ctx.createGain();gain.gain.value=.12;window.quiet=ctx.createGain();quiet.gain.value=0;gain.connect(quiet).connect(ctx.destination);window.osc=ctx.createOscillator();osc.frequency.value=220;osc.connect(gain);osc.start();window.AC={readOutputWaveform:createOutputWaveform(()=>[gain]),setMasterVolume:v=>{gain.gain.value=.12*v;return v}};ctx.resume();`, ); + await js(`window.previewAudio.set({volume:1,muted:false})`); await delay(550); assert.equal( await js( @@ -96,6 +97,17 @@ let win; ), "true", ); + await js(`document.getElementById('piece').dispatchEvent(new Event('did-start-loading'));document.getElementById('piece').dispatchEvent(new Event('did-stop-loading'))`);await delay(250); + assert.equal(await js(`document.getElementById('preview-waveform').classList.contains('sounding')`),true,'Resume polling after a load without another dom-ready'); + assert.equal(await js(`(()=>{const w=document.getElementById('preview-waveform').getBoundingClientRect(),p=document.getElementById('artifact-shell').getBoundingClientRect();return Math.abs(w.top+w.height/2-p.top-p.height/2)<2})()`),true,'Waveform runs behind the visual'); + await js(`(()=>{window.updateProviderFooter({backend:'ac',model:'Luna',busy:true,models:[],versions:[]});document.getElementById('credit-label').click();const v=document.querySelector('input[aria-label="Preview volume"]');v.value='50';v.dispatchEvent(new Event('input'))})()`); + await delay(100);assert(Math.abs(await guest.executeJavaScript('gain.gain.value')-.06)<.001); + await js(`document.querySelector('.preview-mute').click()`);await delay(250); + assert.equal(guest.isAudioMuted(),true); + assert.equal(await js(`document.getElementById('preview-waveform').classList.contains('sounding')`),false); + await js(`(()=>{document.querySelector('.preview-mute').click();const v=document.querySelector('input[aria-label="Preview volume"]');v.value='100';v.dispatchEvent(new Event('input'));document.querySelector('#provider-menu header button').click()})()`);await delay(300); + assert.equal(guest.isAudioMuted(),false); + assert(Math.abs(await guest.executeJavaScript('gain.gain.value')-.12)<.001); for (const width of [380, 900]) { win.setSize(width, 650); await delay(220);