Back to Powder Tool
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool

Release
242076e6da24…
Author-recorded commit
c4489f440f27…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-48e516e34e5

Archive hash verified: f2fd072590aac7a5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/host-ui.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const conformanceRequire = createRequire(require.resolve('@napplet/conformance-cli/package.json'));
const { chromium } = conformanceRequire('playwright');
import assert from 'node:assert/strict';
import { writeFile, mkdir } from 'node:fs/promises';
const previewUrl = process.env.POWDER_PREVIEW_URL;
if (!previewUrl) throw new Error('Set POWDER_PREVIEW_URL to the URL printed by soyli dev');
await mkdir('.napplet-space/promotion', {recursive:true});
const browser=await chromium.launch({headless:true});
const results=[];
const themes={dark:{background:'#15191d',text:'#e6e9e8',primary:'#e8bc72'},light:{background:'#f8f6ed',text:'#252b28',primary:'#28745c'}};
async function sizeFrame(page,w,h){
  await page.setViewportSize({width:Math.max(w,400),height:Math.max(h,400)});
  // Test-only: the manager reserves 300px minimum; override the actual iframe, not screenshot dimensions.
  await page.locator('iframe').evaluate((e,{w,h})=>{e.style.cssText=`position:fixed;left:0;top:0;width:${w}px;height:${h}px;min-height:0;border:0;z-index:10000;`;},{w,h});
  await page.waitForTimeout(100);
  const f=page.frames().find(f=>f.url()==='about:srcdoc');
  assert.deepEqual(await f.evaluate(()=>[innerWidth,innerHeight]),[w,h]);return f;
}
async function theme(page,colors){
  await page.evaluate(colors=>document.querySelector('iframe').contentWindow.postMessage({type:'theme.changed',theme:{colors}},'*'),colors);
  await page.waitForTimeout(60);
}
const page=await browser.newPage({viewport:{width:1200,height:900}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(previewUrl);await page.waitForTimeout(600);
for(const [w,h] of [[200,160],[320,560],[900,600],[2400,1200]]){
 const f=await sizeFrame(page,w,h);
 for(const [name,colors] of Object.entries(themes)){
  await theme(page,colors);
  const info=await f.evaluate(()=>({w:innerWidth,h:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth,bg:getComputedStyle(document.documentElement).getPropertyValue('--bg'),surfaces:['html','body','#app'].map(s=>getComputedStyle(document.querySelector(s)).backgroundColor),canvas:document.querySelector('canvas').getBoundingClientRect().toJSON(),fps:document.querySelector('#fps').textContent}));
  assert.equal(info.overflow,false);assert.equal(info.bg,colors.background);assert.equal(new Set(info.surfaces).size,1);
  await f.locator('#pause').click();const before=await f.locator('#canvas').getAttribute('data-tick');await page.waitForTimeout(80);assert.equal(await f.locator('#canvas').getAttribute('data-tick'),before);
  await f.locator('#step').click();assert.equal(Number(await f.locator('#canvas').getAttribute('data-tick')),Number(before)+1);
  if(w<600){await f.locator('#palette-toggle').click();await f.locator('[data-material="water"]').click();assert.equal(await f.locator('#palette-toggle').getAttribute('aria-expanded'),'false');}
  else await f.locator('[data-material="water"]').click();
  await page.locator('iframe').screenshot({path:`.napplet-space/promotion/frame-${w}x${h}-${name}.png`});
  await f.locator('#pause').click();results.push({size:[w,h],theme:name,info});
 }
}
// Actual SDK persistence, plus canvas editing and undo in the host.
let f=await sizeFrame(page,900,600);await f.locator('#pause').click();await f.locator('#scene').selectOption('empty');
await f.locator('[data-material="sand"]').click();
let box=await f.locator('#canvas').boundingBox();
await page.mouse.move(box.x+box.width*.5,box.y+box.height*.4);await page.mouse.down();await page.mouse.move(box.x+box.width*.65,box.y+box.height*.45,{steps:10});await page.mouse.up();
const painted=Number(await f.locator('#canvas').getAttribute('data-particles'));assert.ok(painted>0);
await f.locator('#save').click();await f.locator('#toast').filter({hasText:'Scene saved'}).waitFor();
await f.locator('#clear').click();assert.equal(Number(await f.locator('#canvas').getAttribute('data-particles')),0);
await f.locator('#load').click();await f.locator('#toast').filter({hasText:'Saved scene restored'}).waitFor();assert.equal(Number(await f.locator('#canvas').getAttribute('data-particles')),painted);
await f.locator('[data-material="box"]').click();await page.mouse.click(box.x+box.width*.45,box.y+box.height*.4);assert.equal(Number(await f.locator('#canvas').getAttribute('data-bodies')),1);
await f.locator('#undo').click();assert.equal(Number(await f.locator('#canvas').getAttribute('data-bodies')),0);
results.push({interaction:'paint/save/clear/load/place/undo',passed:true,particles:painted});
// Resolution changes retain the stage; source editing works entirely through visible controls.
await f.locator('#scene').selectOption('empty');
await f.locator('#resolution').selectOption('300');assert.equal(await f.locator('#canvas').getAttribute('data-width'),'300');
await f.locator('#resolution').selectOption('600');assert.equal(await f.locator('#canvas').getAttribute('data-width'),'600');
await f.locator('#quick-emitter').click();await f.locator('#emitter-material').selectOption('10');
await f.locator('#emitter-direction').selectOption('up');await f.locator('#emitter-place').click();
box=await f.locator('#canvas').boundingBox();const source={x:box.x+box.width*.5,y:box.y+box.height*.4};
await page.mouse.click(source.x,source.y);assert.equal(await f.locator('#canvas').getAttribute('data-emitters'),'1');
assert.equal(await f.locator('#canvas').getAttribute('data-particles'),'0');await f.locator('#step').click();
assert.ok(Number(await f.locator('#canvas').getAttribute('data-particles'))>0);
await page.mouse.click(source.x,source.y);assert.equal(await f.locator('#emitter-material').inputValue(),'10');
await f.locator('#emitter-material').selectOption('11');await f.locator('#emitter-enabled').uncheck();
const particlesBefore=await f.locator('#canvas').getAttribute('data-particles');await f.locator('#step').click();assert.equal(await f.locator('#canvas').getAttribute('data-particles'),particlesBefore);
await f.locator('#emitter-enabled').check();await f.locator('#emitter-direction').selectOption('right');
await f.locator('#emitter-rate').fill('600');await f.locator('#emitter-rate').dispatchEvent('change');
await f.locator('#emitter-radius').fill('8');await f.locator('#emitter-radius').dispatchEvent('change');
await f.locator('#save').click();await f.locator('#toast').filter({hasText:'Scene saved'}).waitFor();
await f.locator('#clear').click();assert.equal(await f.locator('#canvas').getAttribute('data-emitters'),'0');
await f.locator('#load').click();await f.locator('#toast').filter({hasText:'Saved scene restored'}).waitFor();assert.equal(await f.locator('#canvas').getAttribute('data-emitters'),'1');
await f.locator('#quick-emitter').click();await f.locator('#emitter-list').selectOption('1');
assert.equal(await f.locator('#emitter-material').inputValue(),'11');assert.equal(await f.locator('#emitter-rate').inputValue(),'600');assert.equal(await f.locator('#emitter-radius').inputValue(),'8');assert.equal(await f.locator('#emitter-direction').inputValue(),'right');
await f.locator('#quick-grab').click();await page.mouse.move(source.x,source.y);await page.mouse.down();await page.mouse.move(source.x+40,source.y+20,{steps:5});await page.mouse.up();
await f.locator('#quick-emitter').click();await f.locator('#emitter-place').click();await page.mouse.click(source.x+40,source.y+20);assert.equal(await f.locator('#emitter-list').inputValue(),'1');
await f.locator('#emitter-remove').click();assert.equal(await f.locator('#canvas').getAttribute('data-emitters'),'0');
await f.locator('#undo').click();assert.equal(await f.locator('#canvas').getAttribute('data-emitters'),'1');
results.push({interaction:'resolution/emitter/place/step/configure/save/load/move/remove/undo',passed:true});
// A real narrow browser viewport and touch input (not only an iframe resize).
for(const [w,h] of [[390,844],[360,640],[844,390]]){
 const context=await browser.newContext({viewport:{width:w,height:h},isMobile:true,hasTouch:true,deviceScaleFactor:1});const mobile=await context.newPage();mobile.on('pageerror',e=>errors.push(e.message));
 await mobile.goto(previewUrl);await mobile.waitForTimeout(400);
 const mf=mobile.frames().find(f=>f.url()==='about:srcdoc');const actual=await mf.evaluate(()=>({w:innerWidth,h:innerHeight,coarse:matchMedia('(pointer:coarse)').matches,overflow:document.documentElement.scrollWidth>innerWidth}));assert.ok(actual.coarse);assert.equal(actual.overflow,false);
 // Verify actual host corner/menu controls can still be reached.
 assert.ok(await mobile.getByRole('button',{name:'Napplet settings',exact:true}).isVisible());
 await mf.locator('#pause').tap();await mf.locator('#scene').selectOption('empty');
 if(actual.w<600)await mf.locator('#palette-toggle').tap();
 await mf.locator('[data-material="water"]').tap();
 await mf.locator('#canvas').scrollIntoViewIfNeeded(); const bounds=await mf.locator('#canvas').boundingBox();await mobile.touchscreen.tap(bounds.x+bounds.width*.5,bounds.y+bounds.height*.5);
 assert.ok(Number(await mf.locator('#canvas').getAttribute('data-particles'))>0);
 if(actual.w<600)await mf.locator('#palette-toggle').tap();await mf.locator('[data-material="stone"]').tap();
 await mf.locator('#canvas').scrollIntoViewIfNeeded(); const bodyBounds=await mf.locator('#canvas').boundingBox(); await mobile.touchscreen.tap(bodyBounds.x+bodyBounds.width*.5,bodyBounds.y+bodyBounds.height*.3);assert.equal(Number(await mf.locator('#canvas').getAttribute('data-bodies')),1);
 if(actual.w<600)await mf.locator('#palette-toggle').tap();
 await mf.locator('#undo').tap();if(actual.w<600)await mf.locator('#palette-toggle').tap();
 assert.equal(Number(await mf.locator('#canvas').getAttribute('data-bodies')),0);
 // Touch source setup, placement, step and deletion.
 await mf.locator('#quick-emitter').tap();await mf.locator('#emitter-material').selectOption('12');await mf.locator('#emitter-direction').selectOption('down');await mf.locator('#emitter-place').tap();
 await mf.locator('#canvas').scrollIntoViewIfNeeded();const eb=await mf.locator('#canvas').boundingBox();
 await mobile.touchscreen.tap(eb.x+eb.width*.5,eb.y+eb.height*.45);assert.equal(await mf.locator('#canvas').getAttribute('data-emitters'),'1');
 await mf.locator('#step').tap();
 await mf.locator('#quick-emitter').tap();await mf.locator('#emitter-list').selectOption('1');await mf.locator('#emitter-enabled').uncheck();await mf.locator('#emitter-remove').tap();assert.equal(await mf.locator('#canvas').getAttribute('data-emitters'),'0');
 if(actual.w<600)await mf.locator('#emitter-place').tap();
 const targets=await mf.locator('.toolbar button:visible').evaluateAll(es=>es.map(e=>({id:e.id,w:e.getBoundingClientRect().width,h:e.getBoundingClientRect().height})));assert.ok(targets.every(b=>b.h>=44&&b.w>=44));
 await mf.locator('#scene').selectOption('playground');await mf.locator('#pause').tap();await mobile.waitForTimeout(400);
 await mobile.locator('iframe').screenshot({path:`.napplet-space/promotion/mobile-${w}x${h}.png`});results.push({mobile:[w,h],actual,targets,passed:true});await context.close();
}
assert.deepEqual(errors,[]);await writeFile('.napplet-space/promotion/ui-results.json',JSON.stringify(results,null,2));console.log('Passed: 8 frame/theme checks, save/load/edit and emitter flows, 3 touch viewports. Details: .napplet-space/promotion/ui-results.json');await browser.close();