SOURCE / PINNED RELEASE
Made of little things.
Sketch Loop
- Release
- 14966bba4a2a…
- Author-recorded commit
- e68361295d81…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-929bc2a88cb
Archive hash verified: 5e0385e3c08f944f…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { config, fs, storage, themeGet, themeOnChanged, type Subscription, type Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { Sketch, examples } from './sketch.js';
import { renderGif, type ExportOptions, type ExportResult } from './export.js';
import './styles.css';
const el = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const app=el('app'),code=el<HTMLTextAreaElement>('code'),example=el<HTMLSelectElement>('example');
const duration=el<HTMLInputElement>('duration'),start=el<HTMLInputElement>('start'),fps=el<HTMLSelectElement>('fps'),scale=el<HTMLSelectElement>('scale'),loop=el<HTMLInputElement>('loop');
const run=el<HTMLButtonElement>('run'),pause=el<HTMLButtonElement>('pause'),restart=el<HTMLButtonElement>('restart'),exportButton=el<HTMLButtonElement>('export'),saveGif=el<HTMLButtonElement>('save-gif'),saveDraft=el<HTMLButtonElement>('save-draft');
const subscriptions: Subscription[]=[];
let sketch: Sketch | null=null,playing=false,raf=0,lastTime=0,warmup=0,dirty=false,editRevision=0,runningSource='';
let exportAbort:AbortController|null=null,result:ExportResult|null=null,resultUrl:string|null=null;
let configured=false,settingsEdited=false,saveInProgress=false,activeCredit=examples[0].credit;
function status(message:string,kind='idle'):void { el('status').textContent=message;el('status').dataset.kind=kind; }
function failure(error:unknown):void { status(error instanceof Error?error.message:String(error),'error'); }
function chooseView(view:string):void {
app.dataset.view=view;
document.querySelectorAll<HTMLButtonElement>('.view-tabs button').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));
}
function updateCode():void {
el('line-numbers').textContent=Array.from({length:code.value.split('\n').length},(_,i)=>i+1).join('\n');
el('code-count').textContent=`${code.value.length.toLocaleString()} chars`;
el('edit-state').textContent=dirty?'Edited · press Run':'Ready to edit';
}
function options():ExportOptions { return {duration:duration.valueAsNumber,start:start.valueAsNumber,fps:Number(fps.value),scale:Number(scale.value),loop:loop.checked}; }
function summary():void {
const o=options();el('export-summary').textContent=Number.isFinite(o.duration)?`${Math.round(o.duration*o.fps)} frames`:'';
}
function setPlaying(value:boolean):void {
playing=value;pause.textContent=playing?'Ⅱ':'▶';pause.setAttribute('aria-label',playing?'Pause preview':'Resume preview');pause.title=playing?'Pause preview':'Resume preview';
el('play-state').textContent=playing?'Live preview':'Paused';el('live-dot').classList.toggle('paused',!playing);
}
function begin(source:string,prime=false):boolean {
try {
const next=new Sketch(source);next.step();sketch=next;runningSource=source;warmup=prime?599:0;
el('canvas-slot').replaceChildren(next.canvas);
el('dimensions').textContent=`${next.canvas.width} × ${next.canvas.height}`;
el('credit').textContent=activeCredit;lastTime=performance.now();setPlaying(next.looping);
dirty=code.value!==source;updateCode();status('Preview running. Edit the code, then press Run.','ok');return true;
}catch(error){setPlaying(false);failure(error);return false;}
}
function animate(now:number):void {
raf=requestAnimationFrame(animate);
if(!playing||!sketch||document.hidden||exportAbort)return;
if(now-lastTime<1000/sketch.fps)return;
lastTime=now;
try{
const steps=warmup>0?Math.min(6,warmup):1;
for(let i=0;i<steps;i++)sketch.step();warmup=Math.max(0,warmup-steps);
el('frame-label').textContent=`FRAME ${String(sketch.frame).padStart(5,'0')}`;
el('dimensions').textContent=`${sketch.canvas.width} × ${sketch.canvas.height}`;
el('fps-label').textContent=`${sketch.fps} fps`;
if(!sketch.looping)setPlaying(false);
}catch(error){setPlaying(false);failure(error);}
}
function applyTheme(theme:Theme):void {
if(!theme?.colors||!['background','text','primary'].every(key=>typeof (theme.colors as unknown as Record<string,unknown>)[key]==='string'))return;
const style=document.documentElement.style;
style.setProperty('--bg',theme.colors.background);style.setProperty('--fg',theme.colors.text);style.setProperty('--primary',theme.colors.primary);
// Accent text contrast is chosen from the actual computed accent color.
const probe=document.createElement('span');probe.style.color=theme.colors.primary;document.body.append(probe);
const rgb=getComputedStyle(probe).color.match(/[\d.]+/g)?.slice(0,3).map(Number);probe.remove();
const luminance=rgb?rgb.reduce((sum,c,i)=>sum+(c/255<=.04045?c/255/12.92:((c/255+.055)/1.055)**2.4)*[.2126,.7152,.0722][i],0):1;
style.setProperty('--accent-ink',luminance>.179?'#111111':'#ffffff');
const bg=theme.colors.background.replace('#','');
if(/^[\da-f]{6}$/i.test(bg))style.colorScheme=parseInt(bg.slice(0,2),16)+parseInt(bg.slice(2,4),16)+parseInt(bg.slice(4,6),16)>400?'light':'dark';
}
function applyConfig(values:Record<string,unknown>):void {
if(!settingsEdited){
if(typeof values.duration==='number')duration.value=String(Math.min(30,Math.max(1,values.duration)));
if(typeof values.start==='number')start.value=String(Math.min(30,Math.max(0,values.start)));
if([10,15,20,25,30].includes(Number(values.fps)))fps.value=String(values.fps);
summary();
}
app.classList.toggle('smooth',values.pixelated===false);
document.documentElement.style.setProperty('--pixel-rendering',values.pixelated===false?'auto':'pixelated');
configured=true;
}
function toggleBusy(busy:boolean):void {
for(const button of [run,pause,restart,exportButton,saveDraft])button.disabled=busy;
example.disabled=busy;code.readOnly=busy;
for(const input of [duration,start,fps,scale,loop])input.disabled=busy;
el('cancel').hidden=!busy;el('progress-area').hidden=!busy;
}
function clearResult():void {
if(resultUrl)URL.revokeObjectURL(resultUrl);resultUrl=null;result=null;
el<HTMLImageElement>('gif-preview').removeAttribute('src');el('result').hidden=true;
}
code.value=examples[0].code;updateCode();summary();
document.querySelectorAll<HTMLButtonElement>('.view-tabs button').forEach(b=>b.addEventListener('click',()=>chooseView(b.dataset.view!)));
code.addEventListener('input',()=>{dirty=true;editRevision++;example.value='custom';activeCredit='Custom sketch';updateCode();});
code.addEventListener('scroll',()=>{el('line-numbers').scrollTop=code.scrollTop;});
code.addEventListener('keydown',event=>{if(event.key==='Tab'){event.preventDefault();code.setRangeText(' ',code.selectionStart,code.selectionEnd,'end');code.dispatchEvent(new Event('input'));}});
run.addEventListener('click',()=>{if(begin(code.value))chooseView('preview');});
pause.addEventListener('click',()=>{if(sketch){if(!sketch.looping){status('This sketch called noLoop(). Press Run to restart it.');return;}warmup=0;setPlaying(!playing);}});
restart.addEventListener('click',()=>{if(sketch)begin(runningSource);});
example.addEventListener('change',()=>{
const selected=examples[Number(example.value)];if(!selected)return;
code.value=selected.code;activeCredit=selected.credit;editRevision++;dirty=false;updateCode();begin(code.value,true);chooseView('preview');
});
for(const input of [duration,start,fps,scale,loop])input.addEventListener('input',()=>{settingsEdited=true;summary();});
el('help-toggle').addEventListener('click',()=>{el('help').hidden=false;el('help-toggle').setAttribute('aria-expanded','true');el('close-help').focus();});
el('close-help').addEventListener('click',()=>{el('help').hidden=true;el('help-toggle').setAttribute('aria-expanded','false');el('help-toggle').focus();});
el('help').addEventListener('keydown',event=>{if(event.key==='Escape')el('close-help').click();});
el('settings').addEventListener('click',()=>{try{config.openSettings();}catch(error){failure(error);}});
saveDraft.disabled=!runtimeHasDomain('storage');
if(saveDraft.disabled)saveDraft.title='Draft saving is unavailable in this host.';
saveDraft.addEventListener('click',async()=>{
saveDraft.disabled=true;
try{await storage.setItem('sketch-loop:draft',JSON.stringify({code:code.value,options:options()}));status('Draft and export settings saved.','ok');}
catch(error){failure(error);}finally{saveDraft.disabled=!runtimeHasDomain('storage')||!!exportAbort;}
});
exportButton.addEventListener('click',async()=>{
if(!duration.reportValidity()||!start.reportValidity())return;
if(exportAbort||saveInProgress)return;
const source=code.value,exportOptions=options();
exportAbort=new AbortController();toggleBusy(true);clearResult();chooseView('export');
status('Rendering your GIF. You can cancel at any time.');
try{
result=await renderGif(source,exportOptions,exportAbort.signal,(fraction,phase)=>{el<HTMLProgressElement>('progress').value=fraction;el('progress-label').textContent=`${phase} · ${Math.round(fraction*100)}%`;});
resultUrl=URL.createObjectURL(new Blob([new Uint8Array(result.bytes)],{type:'image/gif'}));
el<HTMLImageElement>('gif-preview').src=resultUrl;el('result').hidden=false;
el('result-details').textContent=`${result.width} × ${result.height} · ${result.duration}s · ${(result.bytes.length/1024/1024).toFixed(2)} MB`;
saveGif.disabled=!runtimeHasDomain('fs');saveGif.title=saveGif.disabled?'This host does not support saving files.':'';
status(runtimeHasDomain('fs')?'GIF ready. Save it using the host’s file controls.':'GIF ready to preview. File saving is unavailable in this host.','ok');
}catch(error){if(error instanceof DOMException&&error.name==='AbortError')status('Export cancelled. Your sketch is unchanged.');else failure(error);}
finally{exportAbort=null;toggleBusy(false);saveDraft.disabled=!runtimeHasDomain('storage');lastTime=performance.now();}
});
el('cancel').addEventListener('click',()=>exportAbort?.abort());
saveGif.addEventListener('click',async()=>{
if(!result||saveInProgress)return;saveInProgress=true;saveGif.disabled=true;exportButton.disabled=true;
try{
const info=await fs.info();
if(!Number.isFinite(info.limits.maxWriteBytes)||info.limits.maxWriteBytes<1)throw new Error('This host cannot write file bytes.');
const picked=await fs.pickSaveFile({suggestedName:`sketch-${result.duration}s.gif`,description:'Save rendered GIF',permissions:['write'],accept:[{mime:'image/gif',extension:'.gif'}]});
const entry=picked.entries[0];if(!entry||entry.kind!=='file'||!entry.permissions.includes('write'))throw new Error('The host did not grant a writable file.');
const chunkSize=Math.min(info.limits.maxWriteBytes,256*1024),bytes=result.bytes;
for(let offset=0;offset<bytes.length;offset+=chunkSize){
const chunk=bytes.subarray(offset,Math.min(offset+chunkSize,bytes.length));let binary='';
for(let i=0;i<chunk.length;i+=8192)binary+=String.fromCharCode(...chunk.subarray(i,i+8192));
const written=await fs.write(entry.path,btoa(binary),{mode:offset===0?'replace':'append'});
if(written.bytesWritten!==chunk.length)throw new Error('The host wrote only part of the GIF. Please save again.');
status(`Saving GIF · ${Math.round((offset+chunk.length)/bytes.length*100)}%`);
}
status(`Saved ${entry.name}. Use the host’s file controls to download it.`, 'ok');
}catch(error){failure(error);}finally{saveInProgress=false;saveGif.disabled=false;exportButton.disabled=false;}
});
el('settings').hidden=!runtimeHasDomain('config');
el('capabilities').textContent=`Host features · ${['theme','storage','config','fs'].map(domain=>`${domain}: ${runtimeHasDomain(domain)?'available':'unavailable'}`).join(' · ')}`;
if(runtimeHasDomain('theme')){
void Promise.resolve().then(()=>themeGet()).then(applyTheme).catch(()=>{});
try{subscriptions.push(themeOnChanged(applyTheme));}catch{/* fallback palette remains */}
}
if(runtimeHasDomain('config')){
void Promise.resolve().then(()=>config.get()).then(values=>{if(!configured)applyConfig(values);}).catch(()=>{});
try{subscriptions.push(config.subscribe(applyConfig));}catch{/* local export controls remain */}
}
begin(code.value,true);raf=requestAnimationFrame(animate);
if(runtimeHasDomain('storage')){
const initialRevision=editRevision;
void Promise.resolve().then(()=>storage.getItem('sketch-loop:draft')).then(raw=>{
if(!raw||editRevision!==initialRevision||exportAbort)return;
const draft=JSON.parse(raw);
if(typeof draft.code!=='string'||draft.code.length>24000)return;
code.value=draft.code;example.value='custom';activeCredit='Saved sketch';dirty=false;updateCode();
if(draft.options&&!settingsEdited){
const o=draft.options;
if(Number.isFinite(o.duration)&&o.duration>=1&&o.duration<=30)duration.value=String(o.duration);
if(Number.isFinite(o.start)&&o.start>=0&&o.start<=30)start.value=String(o.start);
if([10,15,20,25,30].includes(o.fps))fps.value=String(o.fps);
if([.5,1].includes(o.scale))scale.value=String(o.scale);
if(typeof o.loop==='boolean')loop.checked=o.loop;
settingsEdited=true;summary();
}
// Stored code is restored for review; it runs only on an explicit click.
status('Saved draft restored. Press Run to preview it.','ok');dirty=true;updateCode();
}).catch(()=>{status('Draft restore unavailable. The example is ready to use.');});
}
window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);exportAbort?.abort();subscriptions.forEach(s=>s.close());clearResult();},{once:true});