/* Standalone Feedback controllers. No requests, telemetry or runtime dependencies. */ (() => { const mounted = new WeakMap(); const motion = {"duration":{"short-2":100,"short-4":200,"medium-1":250,"medium-2":300,"medium-3":350,"medium-4":400}}; function mountFeedback(root, { onAction, returnFocus, timeout = 5000 } = {}) { if (mounted.has(root)) return mounted.get(root); if (!root?.matches('.fb-demo')) throw new TypeError('Feedback needs its .fb-demo root.'); if (onAction !== undefined && typeof onAction !== 'function') throw new TypeError('onAction must be a function.'); if (!Number.isFinite(timeout) || timeout < 0) throw new TypeError('timeout must be a non-negative number.'); const doc=root.ownerDocument, view=doc.defaultView, kind=root.dataset.feedback; const known=['toast','undo','stack','alert','connection','linear','circular','skeleton','loading','retry']; if(!known.includes(kind)) throw new TypeError('Unknown Feedback kind.'); const q=s=>root.querySelector(s), all=s=>[...root.querySelectorAll(s)]; const lifecycle=new view.AbortController(), reduced=view.matchMedia('(prefers-reduced-motion: reduce)'); const timers=new Set(); let destroyed=false, revision=0, request=null, raf=0, progress=0, target=0, mode='determinate', state='idle'; let queue=[], serial=3, expiryTimer=0, expiryRemaining=0, expiryAt=0, inView=true; for(const button of all('button')){button.type='button';button.disabled=false;} const baseline=[...root.childNodes].map(node=>node.cloneNode(true)); function later(fn,ms){const timer=view.setTimeout(()=>{timers.delete(timer);if(!destroyed)fn();},ms);timers.add(timer);return timer;} function clearTimer(timer){view.clearTimeout(timer);timers.delete(timer);} function announce(text,error=false){q('[data-live]').textContent=error?'':text;q('[data-error]').textContent=error?text:'';} function emit(name,detail={}){root.dispatchEvent(new view.CustomEvent('feedback'+name,{bubbles:true,composed:true,detail:{kind,state,...detail}}));} function text(selector,value){const node=q(selector);if(node)node.textContent=String(value);} function active(){return root.getRootNode().activeElement||doc.activeElement;} function focusAway(region){if(!region?.contains(active()))return;const destination=returnFocus||q('[data-demo="show"]')||q('[data-demo="add"]')||q('[data-demo="remove"]')||q('button[data-demo]')||root;destination.focus({preventScroll:true});} function glyph(name){ const svg=q('.fb-icon svg');if(!svg)return; const shapes={check:'',info:'',error:'',file:'',wifi:'',retry:''}; // Only this fixed icon dictionary is used as markup. Application messages use textContent. svg.innerHTML=shapes[name]||shapes.info; } function syncButtons(){ for(const button of all('[data-action="undo"],[data-action="reconnect"],[data-action="retry"]')){ const blocked=state==='pending'||((kind==='undo'||kind==='connection')&&state==='complete'); button.setAttribute('aria-disabled',String(blocked));button.setAttribute('aria-busy',String(state==='pending')); } for(const button of all('[data-demo="start"],[data-demo="load"]'))button.disabled=state==='pending'; for(const button of all('[data-demo="cancel"]'))button.disabled=state!=='pending'; } function setState(value){ state=value;root.dataset.state=value;syncButtons(); const busy=value==='pending'; if(kind==='undo'){ text('[data-title]',busy?'Restoring item':value==='complete'?'Item restored':value==='error'?'Could not restore':'Item removed'); text('[data-detail]',busy?'Please wait a moment.':value==='complete'?'Back where it belongs.':value==='error'?'Nothing changed. Try Undo again.':'You can still bring it back.'); text('[data-action="undo"]',busy?'Restoring…':value==='complete'?'Restored':'Undo');glyph(value==='complete'?'check':'file'); }else if(kind==='connection'){ text('[data-title]',busy?'Reconnecting':value==='complete'?'You are connected':value==='error'?'Still offline':'Connection lost'); text('[data-detail]',busy?'Checking the connection…':value==='complete'?'Your workspace is ready.':'Your changes are safe here.'); text('[data-state-label]',busy?'CONNECTING':value==='complete'?'CONNECTED':'OFFLINE'); text('[data-action="reconnect"]',busy?'Connecting…':value==='complete'?'Connected':'Reconnect');glyph(value==='complete'?'check':'wifi'); }else if(kind==='retry'){ text('[data-title]',busy?'Trying again':value==='complete'?'Files are ready':'Could not load files'); text('[data-detail]',busy?'Checking your request…':value==='complete'?'You can continue where you left off.':'Something interrupted the request. Your files have not changed.'); text('[data-state-label]',busy?'Please wait':value==='complete'?'Up to date':'Request failed'); text('[data-action="retry"]',busy?'Trying…':value==='complete'?'Refresh':'Try again');glyph(value==='complete'?'check':'retry'); }else if(kind==='loading'){ text('[data-title]',busy?'Syncing workspace':value==='complete'?'Everything is up to date':value==='error'?'Could not sync':value==='canceled'?'Sync canceled':'Ready to sync'); text('[data-detail]',busy?'Getting things ready.':value==='complete'?'You are ready to continue.':value==='error'?'Try again when you are ready.':'Everything stays on this device.'); }else if(kind==='skeleton'){ q('[data-content]').setAttribute('aria-busy',String(value!=='complete')); q('[data-loaded]').setAttribute('aria-hidden',String(value!=='complete'));q('[data-loaded]').inert=value!=='complete'; text('[data-demo="load"]',busy?'Loading…':value==='complete'?'Reload content':'Load content'); }else if(kind==='linear'||kind==='circular')renderProgress(); } function initialState(){return kind==='connection'?'offline':kind==='retry'?'error':kind==='undo'?'removed':'idle';} function clearExpiry(){clearTimer(expiryTimer);expiryTimer=0;expiryRemaining=0;} function expiryPaused(){const panel=q('[data-panel]');return doc.hidden||!inView||panel?.matches(':hover')||panel?.contains(active());} function pauseExpiry(){if(!expiryTimer)return;expiryRemaining=Math.max(0,expiryRemaining-(view.performance.now()-expiryAt));clearTimer(expiryTimer);expiryTimer=0;} function resumeExpiry(){if(expiryTimer||!expiryRemaining||expiryPaused())return;expiryAt=view.performance.now();expiryTimer=later(()=>{expiryTimer=0;expiryRemaining=0;hide();},expiryRemaining);} function refreshExpiry(){if(expiryPaused())pauseExpiry();else resumeExpiry();} function show({title,detail,tone='success',duration=timeout}={}){ if(destroyed)return false; if(!['success','error','info'].includes(tone))throw new TypeError('Unknown notification tone.'); if(!Number.isFinite(duration)||duration<0)throw new TypeError('duration must be a non-negative number.'); clearExpiry();root.dataset.tone=tone; if(kind==='toast'){ const messages={success:['Changes saved','Your workspace is up to date.'],error:['Could not save','Your work is safe. Please try again.'],info:['A new update','There is a new note on this project.']}; text('[data-title]',title??messages[tone][0]);text('[data-detail]',detail??messages[tone][1]);glyph(tone==='success'?'check':tone); }else if(kind==='undo'){cancelRequest();setState('removed');} const panel=q('[data-panel]');if(!panel)return false; panel.dataset.visible='true';panel.inert=false;panel.removeAttribute('aria-hidden'); announce(q('[data-title]')?.textContent||'Notification shown.',tone==='error'); if(kind==='toast'&&tone!=='error'){expiryRemaining=duration;resumeExpiry();} emit('show');return true; } function hide(){ if(destroyed)return;clearExpiry();const panel=q('[data-panel]');if(!panel)return; focusAway(panel);panel.dataset.visible='false';panel.inert=true;panel.setAttribute('aria-hidden','true');announce('Message dismissed.');emit('dismiss'); } function queueLabel(){const count=q('[data-stack]')?.children.length||0;text('[data-queue]',queue.length?`${count} visible · ${queue.length} queued`:`${count} notification${count===1?'':'s'}`);} function appendMessage(message,animate=true){ const item=q('[data-item-template]').content.firstElementChild.cloneNode(true); item.dataset.id=String(++serial);item.querySelector('[data-title]').textContent=message.title;item.querySelector('[data-detail]').textContent=message.detail; item.dataset.visible=animate&&!reduced.matches?'false':'true';q('[data-stack]').append(item); if(animate&&!reduced.matches){void item.offsetHeight;item.dataset.visible='true';} queueLabel();return item.dataset.id; } function push({title='New notification',detail='An update to your workspace.'}={}){ if(destroyed||kind!=='stack')return false; const message={title:String(title),detail:String(detail)}; if(q('[data-stack]').children.length<3)appendMessage(message); else if(queue.length<20)queue.push(message); else{announce('Notification queue is full.');emit('overflow');return false;} queueLabel();announce(queue.length?'Notification added to the queue.':message.title);emit('enqueue');return true; } function dismissItem(item){ if(!item||item.dataset.visible==='false')return;focusAway(item);item.inert=true;item.dataset.visible='false'; later(()=>{item.remove();if(queue.length)appendMessage(queue.shift());queueLabel();},reduced.matches?0:motion.duration['medium-2']); } function clearStack(){if(destroyed||kind!=='stack')return;queue=[];for(const item of all('.fb-stack-item'))dismissItem(item);queueLabel();announce('Notifications cleared.');} function renderProgress(){ const bar=q('[data-progress]');if(!bar)return; bar.dataset.mode=mode==='unknown'?'unknown':'determinate'; if(mode==='unknown')bar.removeAttribute('aria-valuenow');else bar.setAttribute('aria-valuenow',String(Math.round(progress))); const isCircle=kind==='circular'; if(mode==='unknown')text('[data-percent]',isCircle?'Working':'—');else text('[data-percent]',Math.round(progress)+(isCircle?'':'%')); if(q('[data-unit]'))q('[data-unit]').hidden=mode==='unknown'; if(isCircle)q('[data-fill]').style.strokeDashoffset=String(100-progress);else q('[data-fill]').style.transform=`scaleX(${progress/100})`; const done=state==='complete'&&progress>=99.95; text('[data-title]',done?(isCircle?'Files are ready':'Export complete'):state==='error'?'Could not finish':state==='canceled'?'Operation canceled':state==='pending'||state==='complete'?(isCircle?'Preparing files':'Exporting assets'):isCircle?'Ready when you are':'Ready to export'); text('[data-detail]',done?'All assets are prepared':state==='pending'?'Keep this preview open':state==='canceled'?'Start again when you are ready':state==='error'?'Try the operation again':'Waiting to start'); } function update(value){ if(destroyed)return false; if(!Number.isFinite(value))throw new TypeError('Progress must be a finite number.'); target=Math.min(100,Math.max(0,value));view.cancelAnimationFrame(raf);raf=0; if(reduced.matches){progress=target;renderProgress();return true;} const from=progress,started=view.performance.now(),length=motion.duration['medium-4']; function tick(now){if(destroyed)return;const t=Math.min(1,(now-started)/length);const eased=t*t*(3-2*t);progress=from+(target-from)*eased;renderProgress();if(t<1)raf=view.requestAnimationFrame(tick);else raf=0;} raf=view.requestAnimationFrame(tick);return true; } function setMode(value){if(!['determinate','unknown'].includes(value))throw new TypeError('Unknown progress mode.');if(destroyed)return;mode=value;renderProgress();} function cancelRequest(){revision++;request?.abort();request=null;} async function run(action='start'){ if(destroyed||state==='pending')return false; if(typeof onAction!=='function'){announce('Connect the onAction callback to run this operation.',true);return false;} cancelRequest();const id=revision,current=new view.AbortController();request=current; if(kind==='linear'||kind==='circular'){view.cancelAnimationFrame(raf);raf=0;progress=target=0;} setState('pending');announce(kind==='skeleton'?'Loading content.':'Operation started.');emit('change'); try{ await onAction(action,{signal:current.signal,onProgress:value=>{if(!destroyed&&id===revision&&state==='pending'&&mode!=='unknown')update(value);}}); if(destroyed||id!==revision||current.signal.aborted)return false; request=null;mode='determinate';if(kind==='linear'||kind==='circular')update(100); setState('complete');announce(kind==='undo'?'Item restored.':kind==='connection'?'Connected.':kind==='skeleton'?'Content loaded.':'Operation complete.');emit('complete');return true; }catch(error){ if(destroyed||id!==revision)return false;request=null;mode='determinate'; setState(error?.name==='AbortError'?'canceled':'error');announce(error?.name==='AbortError'?'Operation canceled.':'The operation could not complete. Please try again.',error?.name!=='AbortError');emit('error',{message:error?.name==='AbortError'?'canceled':'failed'});return false; } } function cancel(){if(destroyed)return;cancelRequest();view.cancelAnimationFrame(raf);raf=0;mode='determinate';setState('canceled');announce('Operation canceled.');emit('cancel');} function reset(){ if(destroyed)return;const focused=active(),demo=focused?.dataset?.demo,action=focused?.dataset?.action,hadFocus=root.contains(focused); cancelRequest();for(const timer of timers)view.clearTimeout(timer);timers.clear();expiryTimer=expiryRemaining=0;view.cancelAnimationFrame(raf);raf=0;progress=target=0;mode='determinate';queue=[];serial=3; root.replaceChildren(...baseline.map(node=>node.cloneNode(true)));root.dataset.tone='success';setState(initialState());queueLabel(); if(hadFocus){const controls=all('button');(controls.find(el=>(demo&&el.dataset.demo===demo)||(action&&el.dataset.action===action))||q('button[data-demo]')||root).focus({preventScroll:true});} emit('reset'); } const listen=(node,event,fn)=>node.addEventListener(event,fn,{signal:lifecycle.signal}); listen(root,'click',event=>{const button=event.target.closest('button[data-action]');if(!button||!root.contains(button)||button.disabled||button.getAttribute('aria-disabled')==='true')return;const action=button.dataset.action;if(action==='dismiss')hide();else if(action==='dismiss-item')dismissItem(button.closest('.fb-stack-item'));else void run(action);}); for(const event of ['pointerover','pointerout','focusin','focusout'])listen(root,event,()=>view.queueMicrotask(()=>{if(!destroyed)refreshExpiry();})); function pauseMotion(){root.dataset.motionPaused=String(doc.hidden||!inView);refreshExpiry();} listen(doc,'visibilitychange',pauseMotion); listen(reduced,'change',()=>{if(reduced.matches){view.cancelAnimationFrame(raf);raf=0;progress=target;renderProgress();}}); const observer=typeof view.IntersectionObserver==='function'?new view.IntersectionObserver(entries=>{inView=entries[0].isIntersecting;pauseMotion();}):null;observer?.observe(root); setState(initialState());root.dataset.tone='success';queueLabel();pauseMotion(); const controller={show,hide,push,clear:clearStack,update,setMode,run,cancel,reset,get state(){return state;},get progress(){return progress;},get queued(){return queue.length;},destroy(){if(destroyed)return;cancelRequest();destroyed=true;lifecycle.abort();observer?.disconnect();for(const timer of timers)view.clearTimeout(timer);timers.clear();view.cancelAnimationFrame(raf);for(const button of all('button'))button.disabled=true;mounted.delete(root);}}; mounted.set(root,controller);return controller; } window.MatteFeedback = { mount: mountFeedback }; })();