app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
// Exercise the real surface + read-only export; no Generate gate or wiki writes.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadPuppeteer, findChrome } from '../doctor.mjs';
import { composeTraces } from '../public/js/circuit-traces.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const screenshotDir = process.env.TRACE_SCREENSHOTS;
if (screenshotDir) fs.mkdirSync(screenshotDir, { recursive: true });
let passed = 0;
const ok = (name, value) => { assert.ok(value, name); console.log(' ok ' + name); passed++; };
const spec = { seed: 1234, color:'#e0a020', boxes:[{x:30,y:40,width:380,height:240}] };
for (const theme of ['circuit-field', 'circuit-tile']) {
const a = composeTraces({ ...spec, theme });
ok(theme + ' has real routes', a.count > 20);
ok(theme + ' is deterministic', a.src === composeTraces({ ...spec, theme }).src);
ok(theme + ' rerolls', a.src !== composeTraces({ ...spec, theme, seed: 1235 }).src);
}
const registry = http.createServer((req,res) => { res.writeHead(404, { 'Content-Type':'application/json' }); res.end('{}'); });
await new Promise(r => registry.listen(0, '127.0.0.1', r));
const port = await new Promise(r => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); });
const base = 'http://127.0.0.1:' + port;
const server = spawn(process.execPath, ['server.js','--port',String(port),'--ai-thread','circuit integration test ' + port], {
cwd:ROOT, env:{...process.env, ADOM_WIKI_REGISTRY:'http://127.0.0.1:' + registry.address().port}, stdio:'pipe',
});
let browser;
try {
let up = false;
for (let i=0; i<80 && !up; i++) { try { up=(await fetch(base+'/state')).ok; } catch {} if(!up) await new Promise(r=>setTimeout(r,100)); }
assert.ok(up, 'test server starts');
const patch = async body => {
const r = await fetch(base+'/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aiThread:'circuit tests',...body})});
return r;
};
for (const bad of [{circuit:{scale:0}}, {circuit:{density:99}}, {circuit:{clearance:-1}}, {circuit:{strength:'0.2'}}, {circuit:null}, {circuitSeed:1.5}, {circuit:{surprise:true}}]) {
ok('rejects ' + JSON.stringify(bad), (await patch(bad)).status === 400);
}
const showcase = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="600" height="450"><rect width="600" height="450" fill="#29364c"/><circle cx="300" cy="200" r="125" fill="#5780a0"/></svg>');
await patch({ theme:'circuit-field', circuitSeed:1234, headline:'Traces with purpose', subtitle:'Make room for the story', layout:'contained', showcase:{src:showcase,full:false}, show:{logo:false,description:false}, guide:{status:'confirmed',sha:'test',brand:{colors:{primary:'#e0a020',background:'#142030'}}} });
browser = await (await loadPuppeteer()).launch({executablePath:findChrome(),headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
const page = await browser.newPage();
await page.setViewport({width:1680,height:1080,deviceScaleFactor:1});
const errors=[]; page.on('pageerror', e=>errors.push(e.message));
await page.goto(base+'/?render=1',{waitUntil:'networkidle2'});
const settled = async () => {
await page.waitForFunction(() => typeof state !== 'undefined' && state && !window.__tracePending);
await page.evaluate(async()=>{await window.__traceReady; if(window.__traceError) throw Error(window.__traceError);});
};
const setLocal = async body => { await page.evaluate(p=>{wizMerge(state,p);render();},body); await settled(); };
await settled();
const src = () => page.$eval('#trace-art',im=>im.src);
const field = await src();
ok('brand color in generated SVG', decodeURIComponent(field).includes('#e0a020'));
ok('preview clones carry the exact artwork', await page.$$eval('#contexts .trace-art',els=>els.length>0 && els.every(el=>el.src===document.querySelector('#trace-art').src)));
ok('glyph contours and showcase are measured without column/bottom rectangles', await page.evaluate(async()=>{
const m=await import('./js/circuit-traces.mjs'); const boxes=m.keepouts(document.querySelector('#hero'));
return ['text-ink','window'].every(id=>boxes.some(b=>b.id===id))
&& !boxes.some(b=>['textzone','ctextzone','homepage-band','lower'].includes(b.id))
&& boxes.find(b=>b.id==='text-ink').rings.length>20;
}));
const bottomInk = () => page.evaluate(()=>{
const cv=document.createElement('canvas');cv.width=1000;cv.height=625;
const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);
return ctx.getImageData(25,485,950,100).data.filter((v,i)=>i%4===3 && v>10).length;
});
ok('field routes continue through the lower quarter',await bottomInk()>500);
// Independently compare the stencil against Chromium's painted DOM, not
// another Canvas measurement. This catches wrong baselines/font metrics,
// cap trimming, kerning, inline accents and wrapped-line reconstruction.
const inkStyle = await page.addStyleTag({content:`
html,body,#hero { background:black!important; }
#hero > :not(.textzone) { visibility:hidden!important; }
#hero .wordmark { visibility:hidden!important; }
#hero .textzone * { color:white!important;-webkit-text-fill-color:white!important;background:none!important;text-shadow:none!important; }
`});
const domInk = await (await page.$('#hero')).screenshot({encoding:'base64'});
await inkStyle.evaluate(el=>el.remove());
await inkStyle.dispose();
ok('ink stencil covers the actual browser-painted glyphs',await page.evaluate(async png=>{
const m=await import('./js/circuit-traces.mjs'),b=m.keepouts(document.querySelector('#hero'),0).find(b=>b.id==='text-ink');
const mask=new Uint8Array(1000*625);
for(const r of b.rings) for(let y=r[0][1];y<r[2][1];y++) for(let x=r[0][0];x<r[1][0];x++) mask[y*1000+x]=1;
const image=new Image();image.src='data:image/png;base64,'+png;await image.decode();
const cv=document.createElement('canvas');cv.width=1000;cv.height=625;
const ctx=cv.getContext('2d');ctx.drawImage(image,0,0,1000,625);
const pixels=ctx.getImageData(0,0,1000,625).data;let ink=0,miss=0;
for(let i=0;i<mask.length;i++) if(pixels[i*4]>100){ink++;if(!mask[i])miss++;}
return ink>1000 && miss/ink<0.015;
},domInk));
await setLocal({layout:'mirror'});
ok('mirrored composition reroutes the field', field !== await src());
const mirrored=await src();
await setLocal({marqueePreview:true});
ok('homepage overlay toggle preserves geometry', mirrored === await src());
await setLocal({layout:'contained',marqueePreview:false});
ok('returning to a composition reproduces it', field === await src());
await setLocal({fontSize:{title:11},headline:'A longer headline wraps into more lines'});
ok('text changes update the keepout', field !== await src());
await setLocal({headline:'Traces with purpose',fontSize:{title:8}});
if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});
await setLocal({theme:'circuit-tile'});
ok('tile artwork loads in browser', await page.$eval('#trace-art',im=>im.complete && im.naturalWidth===1000));
ok('tile pixels are visible outside the showcase', await page.evaluate(()=>{
const cv=document.createElement('canvas');cv.width=1000;cv.height=625;
const ctx=cv.getContext('2d');ctx.drawImage(document.querySelector('#trace-art'),0,0);
const data=ctx.getImageData(0,0,1000,625).data;
let ink=0; for(let i=3;i<data.length;i+=4) if(data[i]>10) ink++;
return ink>1000 && ctx.getImageData(600,160,1,1).data[3]===0;
}));
ok('tile continues through the lower quarter',await bottomInk()>500);
// A large O must retain its counter; a layout/line/character rectangle
// cannot pass this. Range and font measurements must follow the actual ink.
await setLocal({headline:'O',show:{subtitle:false},fontSize:{title:20}});
ok('letter contours preserve open counters and unused column space',await page.evaluate(async()=>{
const m=await import('./js/circuit-traces.mjs');const hero=document.querySelector('#hero');
const text=m.keepouts(hero,0).find(b=>b.id==='text-ink');
const range=document.createRange();range.selectNodeContents(document.querySelector('#el-title'));
const r=range.getBoundingClientRect(),h=hero.getBoundingClientRect();
const x=(r.left+r.width/2-h.left)*1000/h.width;
// Cap-height O's counter is above the baseline, not the line box center.
const y=(document.querySelector('#el-title').getBoundingClientRect().top+r.width*0.45-h.top)*625/h.height;
const contains=(px,py)=>text.rings.some(r=>px>=r[0][0] && px<r[1][0] && py>=r[0][1] && py<r[2][1]);
return text.rings.length>20 && !contains(x,y) && !contains(390,y)
&& text.rings.some(r=>r[0][0]<x && r[1][0]>x);
}));
await setLocal({headline:'Traces with purpose',show:{subtitle:true},fontSize:{title:8}});
if(screenshotDir) await (await page.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});
const tile=await src();
await setLocal({circuitSeed:4321});
ok('seed control changes visible tile',tile!==await src());
await setLocal({showcase:{full:true}});
ok('full imports disable traces',await page.$eval('#trace-art',im=>!im.getAttribute('src') && getComputedStyle(im).display==='none'));
await setLocal({showcase:{full:false},theme:'midnight'});
ok('existing themes clear the trace layer',await page.$eval('#trace-art',im=>!im.getAttribute('src')));
// Real wizard interaction + API persistence, separate from read-only export.
const ui=await browser.newPage(); await ui.setViewport({width:1440,height:1000});
await ui.goto(base+'/',{waitUntil:'networkidle2'});
await ui.waitForFunction(()=>typeof state!=='undefined' && state);
await ui.evaluate(()=>{wizStep='background';wizardRender();});
await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();
await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);
ok('wizard offers both generators',await ui.$$eval('#wcards .card',els=>['Routed field','Seamless traces'].every(s=>els.some(el=>el.textContent.includes(s)))));
await ui.$eval('#wtrace-controls input[aria-label="Trace seed"]',el=>{el.value='7654';el.dispatchEvent(new Event('change',{bubbles:true}));});
await new Promise(r=>setTimeout(r,500));
ok('wizard persists seed to API',(await (await fetch(base+'/state')).json()).circuitSeed===7654);
await ui.waitForFunction(()=>!window.__tracePending);
const uiArt=await ui.$eval('#trace-art',im=>im.src);
await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();
ok('wizard and export use identical artwork',uiArt===await src());
await ui.bringToFront(); await ui.setViewport({width:1100,height:800});
await new Promise(r=>setTimeout(r,300)); await ui.waitForFunction(()=>!window.__tracePending);
ok('resizing preserves the seeded tile',uiArt===await ui.$eval('#trace-art',im=>im.src));
await ui.setViewport({width:1440,height:1000});
await ui.locator('#wcards .card:has(.sw-circuit-field)').click();
await ui.waitForFunction(()=>state.theme==='circuit-field' && !window.__tracePending);
const uiField=await ui.$eval('#trace-art',im=>im.src);
await page.bringToFront(); await page.reload({waitUntil:'networkidle2'}); await settled();
ok('routed field also matches the export',uiField===await src());
await ui.bringToFront();
const status=await (await fetch(base+'/changes')).json();
ok('change ledger records seed',JSON.stringify(status).includes('circuitSeed'));
await ui.evaluate(()=>{wizStep='headline';wizardRender();});
ok('wizard headline paints before its debounced save',await ui.$eval('.wiz-headline input.hin',el=>{
el.focus();el.value='Instant circuit';el.dispatchEvent(new Event('input',{bubbles:true}));
return document.querySelector('#el-title').textContent==='Instant circuit' && window.__tracePending;
}));
// An unrelated server update can arrive while a text save is pending.
await patch({guides:false});
ok('incoming state does not roll back the active draft',await ui.$eval('#el-title',el=>el.textContent==='Instant circuit'));
await ui.waitForFunction(()=>!window.__tracePending && Object.keys(textDrafts).length===0);
ok('headline save reaches the API and reroutes',
(await (await fetch(base+'/state')).json()).headline==='Instant circuit' && uiField!==await ui.$eval('#trace-art',im=>im.src));
ok('wizard subtitle paints immediately',await ui.$$eval('.wiz-headline input.hin',els=>{
const el=els[1];el.focus();el.value='Updates as you type';el.dispatchEvent(new Event('input',{bubbles:true}));
return document.querySelector('#el-sub').textContent==='Updates as you type';
}));
await ui.waitForFunction(()=>Object.keys(textDrafts).length===0);
ok('subtitle save reaches the API',(await (await fetch(base+'/state')).json()).subtitle==='Updates as you type');
await ui.click('#wiz-classic');
ok('Classic headline also paints immediately',await ui.$eval('#headline-input',el=>{
el.focus();el.value='Fast in Classic';el.dispatchEvent(new Event('input',{bubbles:true}));
return document.querySelector('#el-title').textContent==='Fast in Classic';
}));
await ui.waitForFunction(()=>Object.keys(textDrafts).length===0);
await ui.evaluate(()=>{document.activeElement.blur();});
await patch({headline:'Traces with purpose',subtitle:'Make room for the story',fontSize:{title:8}});
await ui.waitForFunction(()=>state.headline==='Traces with purpose' && !window.__tracePending);
ok('saved drafts yield to later server edits',await ui.$eval('#el-title',el=>el.textContent==='Traces with purpose'));
await ui.evaluate(()=>{document.body.classList.add('wizard');wizStep='background';wizardRender();});
if(process.env.TRACE_SHOWCASE) {
await patch({showcase:{src:'data:image/png;base64,'+fs.readFileSync(process.env.TRACE_SHOWCASE).toString('base64'),full:false},headline:'Circuit traces',subtitle:'Made for your composition',fontSize:{title:8},guide:{status:'confirmed',brand:{colors:{primary:'#00b8b1',background:'#0d1117'}}}});
await ui.waitForFunction(()=>state.headline==='Circuit traces' && !window.__tracePending);
}
if(screenshotDir) {
await ui.screenshot({path:path.join(screenshotDir,'wizard-field.png')});
await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'field.png')});
await ui.locator('#wcards .card:has(.sw-circuit-tile)').click();
await ui.waitForFunction(()=>state.theme==='circuit-tile' && !window.__tracePending);
await ui.screenshot({path:path.join(screenshotDir,'wizard.png')});
await (await ui.$('#hero')).screenshot({path:path.join(screenshotDir,'tile.png')});
}
ok('no browser runtime errors', errors.length===0);
console.log('\n'+passed+' circuit checks passed');
} finally {
if(browser) await browser.close(); server.kill(); registry.close();
}