diff --git a/plugins/playgrounds/controls.ts b/plugins/playgrounds/controls.ts index 35429b1b..73bdba2c 100644 --- a/plugins/playgrounds/controls.ts +++ b/plugins/playgrounds/controls.ts @@ -297,6 +297,13 @@ export const CONTROLS_RUNTIME = String.raw` function define(name, spec) { if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error("Parameter names are letters, digits, - and _: " + name); const s = { label: name, ...spec }; + // Programmatic declarations commonly use JavaScript type names and bare + // choices. Normalize them to the same schema as data-pg controls before + // validating values, so a valid choice cannot silently reset to default. + if (s.type === "string") s.type = "text"; + if (s.type === "boolean") s.type = "toggle"; + if (Array.isArray(s.options)) s.options = s.options.map((option) => + typeof option === "string" || typeof option === "number" ? { value: option, label: String(option) } : option); if (s.type === "grid") s.default = s.default || Array.from({ length: s.rows }, () => Array(s.steps).fill(false)); else if (s.type === "board") s.default = Array.isArray(s.default) ? s.default : Array.from({ length: s.rows }, () => Array(s.cols).fill("")); else if (s.type === "text") s.default = s.default === undefined ? "" : String(s.default); diff --git a/plugins/playgrounds/evals/launch/README.md b/plugins/playgrounds/evals/launch/README.md index c8ed6121..be903f74 100644 --- a/plugins/playgrounds/evals/launch/README.md +++ b/plugins/playgrounds/evals/launch/README.md @@ -55,7 +55,7 @@ and the actual dev UI. Infrastructure failures here abort without counting forty spurious product failures. The tiny preflight fixture is not an eval prompt and is never given to an agent. Generic fixtures also verify live pointer, click-only and keyboard-only surfaces, output changes while a page resizes, and -geometry inside the tested drawing. Matching inert fixtures change only +geometry inside the tested drawing, and independent `role="status"` readouts. Matching inert fixtures change only hover/focus/color/padding; they must stay unproven. Mixed stable/ticking readouts, selected sibling choices, and a runtime exception with an otherwise working control are covered too. Saved-card replay scripts can @@ -73,8 +73,15 @@ non-default model for Personal, reset the **isolated instance** before measuring - Time starts immediately before the HTTP prompt send. The real thread page is opened in Chrome. A loaded iframe or native card must be on screen, with a - nonblank screenshot. A tool return or stored HTML alone never counts. The - observer polls at 150 ms; loading and browser startup stay in the measurement. + nonblank screenshot of its exposed interior. The capture keeps the same DOM + node, source, bounds, scroll position and layout version before and after the + screenshot; a replaced/moved frame is retried, never replaced by a body capture. + Surrounding thread text and host overlays cannot supply the required pixels. + Time ends at accepted screenshot completion, an observed upper bound. The + iframe load time is retained separately. A tool return or stored HTML alone + never counts; loading and browser startup stay in the measurement. Generic + preflight cases cover blank frames, delayed paint, replacement and scrolling. + `visible-capture.json` records every attempt and rejection. - Progress evidence contains assistant text observed in visible message columns before the card, matched to real timeline rows. Tool logs are not progress. - Question evidence includes structured `user_question` interactions and assistant diff --git a/plugins/playgrounds/evals/launch/controls.mjs b/plugins/playgrounds/evals/launch/controls.mjs index 1b099423..8b9a26b1 100644 --- a/plugins/playgrounds/evals/launch/controls.mjs +++ b/plugins/playgrounds/evals/launch/controls.mjs @@ -41,7 +41,7 @@ export function inventory(scope = 'body') { export function captureEvidence(scope,probeId,frameIndex) { const root=document.querySelector(scope),rootRect=root.getBoundingClientRect(); const ordinary='button,input,select,textarea,label,[role="button"],[role="switch"],[role="checkbox"],[role="radio"],[role="tab"]'; - const candidates=[...root.querySelectorAll('svg,canvas,output,[data-readout],[aria-live],.pg-sentence,.pg-readouts dd,p,dd,td')] + const candidates=[...root.querySelectorAll('svg,canvas,output,[data-readout],[aria-live],[role="status"],.pg-sentence,.pg-readouts dd,p,dd,td')] .filter(el => !el.closest(ordinary) && !el.parentElement?.closest('svg,canvas')); // Prefer the outer named output if nested selectors describe the same text. const outputs=candidates.filter(el => !candidates.some(parent => parent!==el && parent.contains(el))); diff --git a/plugins/playgrounds/evals/launch/fixtures.mjs b/plugins/playgrounds/evals/launch/fixtures.mjs index 2166a4fd..3da662a0 100644 --- a/plugins/playgrounds/evals/launch/fixtures.mjs +++ b/plugins/playgrounds/evals/launch/fixtures.mjs @@ -1,5 +1,7 @@ // Generic harness checks, never supplied to an agent or copied into a skill. export const proofFixtures = [ + {name:'status-readout',expectedPass:true,html:`
Initial result
`}, + {name:'status-cosmetic-only',expectedPass:false,html:`
Unchanged result
`}, {name:'native-disclosure',expectedPass:true,html:`
Show explanation

Additional explanation.

Explanation hidden`}, {name:'caught-error-after-output',expectedPass:false,expectedStatus:'runtime-error',html:`Initial result`}, {name:'runtime-error-with-working-control',expectedPass:false,expectedStatus:'runtime-error',html:`Initial result`}, diff --git a/plugins/playgrounds/evals/launch/run.mjs b/plugins/playgrounds/evals/launch/run.mjs index e1ac1e0c..0f349f8d 100644 --- a/plugins/playgrounds/evals/launch/run.mjs +++ b/plugins/playgrounds/evals/launch/run.mjs @@ -5,6 +5,8 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { checkControls } from './controls.mjs'; import { hasInk } from './pixels.mjs'; +import { captureVisible, discoverCard } from './visibility-capture.mjs'; +import { visibilityFixtures } from './visibility-fixtures.mjs'; import { failureScore, providerSummary, resumeMismatch } from './metrics.mjs'; import { renderWindow } from './observations.mjs'; import { proofFixtures } from './fixtures.mjs'; @@ -44,7 +46,7 @@ const manifestPath = join(out, 'manifest.json'); const commit = args.commit || (() => { try { return execFileSync('git', ['-C', pluginRoot, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim(); } catch { return 'unverified'; } })(); const manifest = { label: args.label, url: base.origin, host: args.host, pluginRoot, commit, capMs, renderGraceMs: 30_000, prompts, providers, modelSelection: 'omitted: default for each provider in fresh Personal project', - concurrency: 1, timing: 'HTTP prompt send until loaded card is visible in real thread DOM; polling resolution 150 ms, screenshot corroboration', + concurrency: 1, timing: 'HTTP prompt send until accepted screenshot of a loaded card in the real thread; stable DOM identity, layout and visible interior; screenshot completion is the observed upper bound', createdAt: new Date().toISOString() }; if (existsSync(manifestPath)) { const old = JSON.parse(readFileSync(manifestPath, 'utf8')); @@ -83,27 +85,17 @@ function questionEvidence(timeline, interactions) { return { structured, textQuestions, count: structured.length + textQuestions.filter(q => q.likely).length, needsReview: textQuestions.some(q => !q.likely) }; } -function monitor(threadId, capAt, renderWindow) { - const visible = el => { - const r = el.getBoundingClientRect(); - return r.width > 80 && r.height > 40 && r.bottom > 0 && r.top < innerHeight && getComputedStyle(el).visibility !== 'hidden'; - }; - const loaded = new WeakSet(); - window.__launch = { proof: null, progress: [], errors: [], done: false, agentCompletedAt: null, terminalObservedAt: null, completionSource: null }; - document.addEventListener('load', event => { if (event.target?.tagName === 'IFRAME' && event.target.src) loaded.add(event.target); }, true); +function monitor(threadId, capAt, renderWindow, discoverCard) { + if (window.top !== window) return; + const loaded = new WeakMap(), identities = {elements:new WeakMap(),next:0}; + window.__launch = { proof: null, candidate: null, progress: [], errors: [], done: false, agentCompletedAt: null, terminalObservedAt: null, completionSource: null }; + document.addEventListener('load', event => { if (event.target?.tagName === 'IFRAME' && event.target.src) loaded.set(event.target, Date.now()); }, true); setInterval(() => { const state = window.__launch; const windowState = renderWindow({now:Date.now(),capAt,agentCompletedAt:state.agentCompletedAt,visibleAt:state.proof?.at ?? null}); state.done = windowState.done; if (state.done) return; - const frame = document.querySelector('.pg-host .pg-widget iframe'); - const card = frame || document.querySelector('.pg-host .pg-answer:not([role="status"]):not(.pg-error)'); - if (!card) return; - card.scrollIntoView({block:'center'}); - if (!visible(card) || (frame && !loaded.has(frame))) return; - card.dataset.launchCard = 'visible'; - window.__launch.proof = { at: Date.now(), kind: frame ? 'html' : 'document', target: frame?.src || location.href, - width: card.getBoundingClientRect().width, height: card.getBoundingClientRect().height }; + state.candidate = discoverCard(loaded, identities); }, 150); const flatten = rows => rows.flatMap(row => [row, ...flatten(row.children || row.childRows || [])]); const normalize = text => text.replace(/[*_`#\[\]]/g, '').replace(/\s+/g, ' ').trim(); @@ -173,6 +165,7 @@ function summarize() { const defaultModels = new Map(); const preflightDir = join(out,'preflight',String(Date.now())); mkdirSync(preflightDir,{recursive:true}); save(join(preflightDir,'server-identity.json'), await verifyIsolation(base, args['data-dir'], args.host)); +await visibilityFixtures({dir:join(preflightDir,'visibility'),tempRoot:chromeTempDir}); await api('/projects/proj_personal'); const plugins = await api('/plugins'); save(join(preflightDir,'plugins.json'),plugins); if (!JSON.stringify(plugins).includes('playgrounds')) throw new Error('Preflight: Playgrounds is not installed'); @@ -193,7 +186,8 @@ for (const axis of ['x','y']) for (const live of [true, false]) { const dragFixture = join(dragDir, 'fixture.html'); writeFileSync(dragFixture, `
0`); const checked = await checkControls({target:dragFixture,width:480,dir:dragDir,drive}); - if (checked.pass !== live || checked.controls.length !== 1 || (!live && checked.controls[0].reason !== 'no proven output change outside controls')) + // Structured verdicts are the contract; diagnostic wording may change. + if (checked.pass !== live || checked.controls.length !== 1 || (!live && (checked.status !== 'unproven' || checked.controls[0].status !== 'unproven' || checked.runtimeErrors.length !== 0))) throw new Error(`Preflight: drag ${live ? 'live' : 'inert'} regression failed; see ${dragDir}/controls.json`); } // A button can change geometry inside a separate interactive drawing. Keep @@ -266,12 +260,10 @@ for (const provider of providers) for (const prompt of selected) { } if (interrupted) throw new Error('Runner interrupted'); const preload = join(dir,'monitor.js'); - writeFileSync(preload, `(${monitor.toString()})(${JSON.stringify(thread.id)},${result.sentAt+capMs},${renderWindow.toString()});`); - const remaining = Math.max(1, capMs - (Date.now()-result.sentAt)); - const visible = await drive(new URL(`/threads/${thread.id}`,base).href, - [['until','window.__launch?.proof || window.__launch?.done',remaining], ['eval','window.__launch'], ['shot',join(dir,'visible.png'),'[data-launch-card="visible"]']], - join(dir,'visible'),prompt.width || 1280,{preload,timeout:remaining+20_000}); - const measurement = visible.evals.find(v => v && Object.hasOwn(v,'proof')); + writeFileSync(preload, `(${monitor.toString()})(${JSON.stringify(thread.id)},${result.sentAt+capMs},${renderWindow.toString()},${discoverCard.toString()});`); + const visible = await captureVisible({target:new URL(`/threads/${thread.id}`,base).href, + preload,dir,tempRoot:chromeTempDir,capAt:result.sentAt+capMs,width:prompt.width || 1280}); + const measurement = visible.measurement; const proof = measurement?.proof; result.progress = measurement?.progress || []; result.browserErrors = measurement?.errors || []; diff --git a/plugins/playgrounds/evals/launch/visibility-capture.mjs b/plugins/playgrounds/evals/launch/visibility-capture.mjs new file mode 100644 index 00000000..494aee87 --- /dev/null +++ b/plugins/playgrounds/evals/launch/visibility-capture.mjs @@ -0,0 +1,130 @@ +// A loaded iframe is only a candidate. Commit a timing proof after capturing +// actual pixels while its DOM identity, bounds and viewport position stay fixed. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {mkdtempSync,readFileSync,writeFileSync,rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {chromePath} from '../../skills/how-it-works/scripts/clip-browser.mjs'; +import {hasInk} from './pixels.mjs'; + +// Runs in the parent page, including before a lazy iframe has loaded. +export function discoverCard(loaded, identities) { + const frame=document.querySelector('.pg-host .pg-widget iframe'); + const card=frame||document.querySelector('.pg-host .pg-answer:not([role="status"]):not(.pg-error)'); + if(!card)return null; + if(frame&&!loaded.has(frame)){card.scrollIntoView({block:'center'});return null;} + const r=card.getBoundingClientRect(); + if(r.width<=80||r.height<=40||getComputedStyle(card).visibility==='hidden')return null; + if(!identities.elements.has(card))identities.elements.set(card,{key:String(++identities.next),at:loaded.get(card)||Date.now()}); + const identity=identities.elements.get(card); + if(card.dataset.launchCard!=='candidate')card.dataset.launchCard='candidate'; + if(card.dataset.launchKey!==identity.key)card.dataset.launchKey=identity.key; + return {...identity,kind:frame?'html':'document',target:frame?.src||location.href}; +} + +// Executed in the parent page. Only measurement state and scrolling are changed. +function candidateBounds(prepare) { + const state=window.__launch; + const el=prepare?document.querySelector('[data-launch-card="candidate"]'):window.__launchCaptureNode; + if(!state?.candidate||!el?.isConnected)return null; + if(prepare){el.scrollIntoView({block:'center'});window.__launchCaptureNode=el;} + const r=el.getBoundingClientRect(),inside=(x,y)=>{const hit=document.elementFromPoint(x,y);return hit===el||el.contains(hit);}; + const left=Math.max(0,r.left)+12,right=Math.min(innerWidth,r.right)-12; + if(right-left<80)return null; + const line=y=>[left,(left+right)/2,right].every(x=>inside(x,y)); + let top=Math.max(0,r.top)+12,bottom=Math.min(innerHeight,r.bottom)-12; + // A long card can be partly covered by bb's header/composer. Capture only the + // exposed interior; border antialiasing and surrounding text are not content. + while(toptop&&!line(bottom))bottom-=8; + if(bottom-top<40||!line((top+bottom)/2))return null; + const key=el.dataset.launchKey; + if(!key||key!==state.candidate.key)return null; + return {key,layoutVersion:window.__launchLayoutVersion,src:el.tagName==='IFRAME'?el.src:null,x:r.x,y:r.y,width:r.width,height:r.height, + scrollX,scrollY,clip:{x:left+scrollX,y:top+scrollY,width:right-left,height:bottom-top,scale:1},candidate:state.candidate}; +} +function sameCapture(a,b){ + return a&&b&&a.key===b.key&&a.src===b.src&&a.layoutVersion===b.layoutVersion&&['x','y','width','height','scrollX','scrollY'].every(k=>Math.abs(a[k]-b[k])<0.5)&& + ['x','y','width','height'].every(k=>Math.abs(a.clip[k]-b.clip[k])<0.5); +} + +export async function captureVisible({target,preload,dir,tempRoot,capAt,width=1280}) { + const binary=chromePath();assert(binary,'No Chrome available'); + const profile=mkdtempSync(join(tempRoot,'launch-visible-')); + const chrome=spawn(binary,['--headless=new','--remote-debugging-port=0','--user-data-dir='+profile, + '--no-first-run','--no-default-browser-check','--hide-scrollbars','about:blank'],{detached:true,stdio:['ignore','ignore','pipe']}); + writeFileSync(join(dir,'visible.pid'),String(chrome.pid)); + let socket,id=0,log='',aborted=false,state=null;const pending=new Map(),attempts=[]; + const sleep=ms=>new Promise(r=>setTimeout(r,ms)); + const stop=()=>{aborted=true;if(chrome.exitCode===null&&chrome.signalCode===null)try{process.kill(-chrome.pid,'SIGTERM')}catch{}}; + process.once('SIGTERM',stop);process.once('SIGINT',stop); + const hardDeadline=setTimeout(stop,Math.max(1,capAt-Date.now()+20000)); + const rpc=(method,params={})=>new Promise((resolve,reject)=>{ + const call=++id,timer=setTimeout(()=>{pending.delete(call);reject(Error('CDP timeout: '+method));},10000); + pending.set(call,{resolve:v=>{clearTimeout(timer);v.error?reject(Error(JSON.stringify(v.error))):resolve(v.result)},reject:e=>{clearTimeout(timer);reject(e)}}); + try{socket.send(JSON.stringify({id:call,method,params}));}catch(error){clearTimeout(timer);pending.delete(call);reject(error);} + }); + const evaluate=async expression=>{const r=await rpc('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true}); + if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result?.value;}; + try{ + const port=await new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>reject(Error('Chrome startup timeout')),20000); + chrome.once('error',e=>{clearTimeout(timer);reject(e)}); + chrome.once('exit',()=>{clearTimeout(timer);reject(Error('Chrome exited before ready'))}); + chrome.stderr.on('data',chunk=>{log=(log+chunk).slice(-8000);const m=log.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//); + if(m){clearTimeout(timer);resolve(Number(m[1]));}}); + }); + const page=await(await fetch('http://127.0.0.1:'+port+'/json/new?about:blank',{method:'PUT',signal:AbortSignal.timeout(10000)})).json(); + socket=new WebSocket(page.webSocketDebuggerUrl); + socket.onmessage=e=>{const r=JSON.parse(e.data);if(r.id&&pending.has(r.id)){pending.get(r.id).resolve(r);pending.delete(r.id);}}; + await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject}); + await rpc('Runtime.enable');await rpc('Page.enable'); + await rpc('Emulation.setDeviceMetricsOverride',{width,height:1100,deviceScaleFactor:1,mobile:width<600}); + await rpc('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]}); + const layoutGuard=`if(window.top===window){ + window.__launchLayoutVersion=0; + new MutationObserver(changes=>{const el=window.__launchCaptureNode; + if(changes.some(m=>!el||m.target===el||!el.contains(m.target)))window.__launchLayoutVersion++; + }).observe(document,{subtree:true,childList:true,attributes:true,characterData:true}); + addEventListener('scroll',()=>window.__launchLayoutVersion++,true); + }`; + await rpc('Page.addScriptToEvaluateOnNewDocument',{source:layoutGuard+'\n'+readFileSync(preload,'utf8')}); + await rpc('Page.navigate',{url:target}); + while(!aborted&&Date.now()<=capAt){ + state=await evaluate('window.__launch || null'); + if(state?.done)break; + if(!state?.candidate){await sleep(100);continue;} + await evaluate(`(${candidateBounds.toString()})(true)`); + // Give layout and the compositor a frame, then record bounds immediately + // before capture. A subsequent scroll/replacement makes this attempt fail. + await sleep(50); + const before=await evaluate(`(${candidateBounds.toString()})(false)`); + if(!before){await sleep(100);continue;} + const startedAt=Date.now(); + const shot=await rpc('Page.captureScreenshot',{format:'png',clip:before.clip,captureBeyondViewport:false}); + const at=Date.now(),after=await evaluate(`(${candidateBounds.toString()})(false)`); + const path=join(dir,'visible-candidate.png');writeFileSync(path,Buffer.from(shot.data,'base64')); + const stable=sameCapture(before,after),ink=stable&&hasInk(path); + state=await evaluate('window.__launch'); + const accepted=stable&&ink&&!state.done&&at<=capAt; + attempts.push({startedAt,at,stable,ink,accepted,before,after}); + if(accepted){ + writeFileSync(join(dir,'visible.png'),Buffer.from(shot.data,'base64')); + state.proof={...before.candidate,at,captureStartedAt:startedAt,loadedAt:before.candidate.at, + width:before.width,height:before.height,capture:'stable-visible-interior',clip:before.clip}; + return {code:0,measurement:state}; + } + await sleep(100); + } + return {code:0,measurement:state}; + }finally{ + writeFileSync(join(dir,'visible-capture.json'),JSON.stringify({attempts,state},null,2)); + writeFileSync(join(dir,'visible-browser.log'),log);clearTimeout(hardDeadline); + for(const call of pending.values())call.reject(Error('Capture closed'));pending.clear(); + socket?.close();stop(); + if(chrome.exitCode===null&&chrome.signalCode===null){await Promise.race([new Promise(r=>chrome.once('exit',r)),sleep(3000)]); + if(chrome.exitCode===null&&chrome.signalCode===null)try{process.kill(-chrome.pid,'SIGKILL')}catch{}} + process.removeListener('SIGTERM',stop);process.removeListener('SIGINT',stop); + rmSync(profile,{recursive:true,force:true}); + } +} diff --git a/plugins/playgrounds/evals/launch/visibility-fixtures.mjs b/plugins/playgrounds/evals/launch/visibility-fixtures.mjs new file mode 100644 index 00000000..6c387737 --- /dev/null +++ b/plugins/playgrounds/evals/launch/visibility-fixtures.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import {mkdirSync,writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {captureVisible,discoverCard} from './visibility-capture.mjs'; + +// Generic measurement regressions, never included in an agent prompt. +export async function visibilityFixtures({dir,tempRoot}) { + const drawing='Working output'; + const quote=s=>s.replaceAll('&','&').replaceAll('"','"'); + for(const name of ['painted','blank','delayed','removed','replaced','scrolled','lazy']) { + const path=join(dir,name);mkdirSync(path,{recursive:true}); + const capAt=Date.now()+4000,preload=join(path,'monitor.js'),file=join(path,'fixture.html'); + // A top-level page can contain plenty of ink while its card is still blank. + const body=name==='blank'||name==='delayed'?'':drawing; + writeFileSync(file,`
Host prose is not card evidence
More host content
`); + writeFileSync(preload,`if(top===window){ + window.__launch={proof:null,candidate:null,done:false,progress:[],errors:[],paintAt:null}; + let replaced=false;const loaded=new WeakMap(),identities={elements:new WeakMap(),next:0}; + document.addEventListener('load',e=>{const el=e.target;if(el?.tagName!=='IFRAME')return; + loaded.set(el,Date.now()); + if(${JSON.stringify(name)}==='delayed')setTimeout(()=>{el.contentDocument.body.innerHTML=${JSON.stringify(drawing)};__launch.paintAt=Date.now()},700); + },true); + ${['removed','replaced'].includes(name)?`let node;Object.defineProperty(window,'__launchCaptureNode',{get:()=>node,set:el=>{node=el;if(replaced)return;replaced=true;queueMicrotask(()=>{el.remove();${name==='replaced'?`setTimeout(()=>{const fresh=document.createElement('iframe');fresh.style.cssText=el.style.cssText;fresh.srcdoc=${JSON.stringify(drawing)};document.querySelector('.pg-widget').prepend(fresh)},300);`:''}})}});`:''} + ${name==='scrolled'?`let node,once=false;Object.defineProperty(window,'__launchCaptureNode',{get:()=>node,set:el=>{node=el;if(once)return;once=true;setTimeout(()=>{el.style.marginTop='600px';window.scrollTo(0,500)},55)}});`:''} + setInterval(()=>{if(Date.now()>=${capAt})__launch.done=true;else __launch.candidate=(${discoverCard.toString()})(loaded,identities)},25); + }`); + const result=await captureVisible({target:pathToFileURL(file).href,preload,dir:path,tempRoot,capAt,width:800}); + const proof=result.measurement?.proof; + assert.equal(Boolean(proof),!['blank','removed'].includes(name),name); + if(proof)assert.equal(proof.capture,'stable-visible-interior'); + if(name==='delayed')assert(proof.at>=result.measurement.paintAt&&result.measurement.paintAt,'Wait for real paint'); + if(name==='replaced')assert.equal(proof.key,'2','Replacement gets its own load/capture identity'); + writeFileSync(join(path,'verdict.json'),JSON.stringify({name,pass:true,proof},null,2)); + } +} diff --git a/plugins/playgrounds/server.ts b/plugins/playgrounds/server.ts index cf86cd60..78287801 100644 --- a/plugins/playgrounds/server.ts +++ b/plugins/playgrounds/server.ts @@ -146,6 +146,7 @@ const HTML_GUIDE = [ "Kit classes: .pg-title, .pg-subtitle, .pg-eyebrow, .pg-h, .pg-item-title, .pg-body, .pg-meta, .pg-panel, .pg-stage, .pg-photos, .pg-seg (buttons with aria-pressed), .pg-chip, .pg-btn, .pg-btn-primary, .pg-link, .pg-check (label > input + text + small), .pg-dots (i[aria-current=step]), .pg-reveal (entrance; set --i to stagger).", "Bridge: window.playground.state is the playground's shared state (or null): the last value passed to window.playground.save(value) on any device, or set by the agent. Call save after each meaningful change; bb stores it and every open copy receives it. window.playground.onState(callback) runs when the state changes elsewhere; apply it without saving again. window.playground.theme and window.playground.onTheme(callback) report theme changes. http(s) links open in bb only after a click inside the answer; call window.playground.send from a click too.", "Agent control: window.playground.expose({ name: (...args) => result }) lists actions the agent can run with `bb playgrounds do --args '[...]'`; return a short JSON-serializable result (a Promise is fine) and drive the same code path a click would. window.playground.emit(name, data) records something the user did for `bb playgrounds watch`. window.playground.send(label, data), called from a click, attaches data to the user's next message as a pill, so they choose when you see it: use it for things like a recorded take or a finished attempt. Expose the handful of verbs a person would use (play, select, set), not internals.", + "Programmatic inputs: playground.params.define(name, spec) before use. Numbers: {type:'number',min:0,max:100,step:1,default:0}; omitted bounds mean 0..1, so declare the entire range of every slider/step index. Choices: {type:'select',options:[{value:'a',label:'A'},{value:'b',label:'B'}],default:'a'}; bare string/number options also work. Text: {type:'text',default:''}; boolean: {type:'toggle',default:false}; string/boolean are accepted aliases. Use params.set(name,value) or params.set({name:value}), read params.get(name)/all(), redraw with params.on(update) and call update once. Test every choice and both endpoints in the published frame, not only a local file without its bridge.", "Anatomy figures: when you build a figure with the how-it-works skill (Anatomy, skills.wheresryan.sh/anatomy), write its standalone page into $BB_THREAD_STORAGE (works in Personal threads without a checkout) or this thread's workspace and publish it with `bb playgrounds publish --playground-file `; the card keeps its styles and theme, and the agent can `read` it and press `key`s on it. Put the model's inputs in playground parameters so `setParams` works, and credit Ryan (@wheresryan22) and Anatomy in your reply.", "Network: the frame has no network access. Scripts, styles, and fonts must be inline; bb supplies Inter. Images load only from data: and blob: URLs and https://upload.wikimedia.org (credit photos in your prose); every other request, including remote scripts, fetch, and map tiles, is blocked. Draw maps and 3D views as inline SVG or canvas. The frame has no access to bb, cookies, or the conversation; share what the agent should see through save and emit only. Respect prefers-reduced-motion and keep controls keyboard accessible.", ].join("\n"); diff --git a/plugins/playgrounds/skills/how-it-works/SKILL.md b/plugins/playgrounds/skills/how-it-works/SKILL.md index fd58a38a..355a1e43 100644 --- a/plugins/playgrounds/skills/how-it-works/SKILL.md +++ b/plugins/playgrounds/skills/how-it-works/SKILL.md @@ -14,234 +14,97 @@ to a quick figure; it retains the fuller Anatomy workflow for deeper requests. Prebuilt example pages are omitted; each example's `build.mjs` writes them again. Credit Ryan and Anatomy whenever you show a figure. -## In bb - -- **Work on a copy in this thread's storage.** Personal threads need no Git - checkout. Copy this skill's folder (the one holding this `SKILL.md`) to a - fresh directory under `$BB_THREAD_STORAGE`; builds write beside their script. - For example, `figure_dir=$(mktemp -d "$BB_THREAD_STORAGE/figure-XXXXXX")` - then `cp -R /. "$figure_dir/"`. Build and edit that copy, - never the installed skill. No Git commands or repository setup are needed. -- **Build before you open an example.** The examples ship as sources only. Run - `node examples//build.mjs` in your copy to get its page. -- **Show the figure as a playground.** Use the framework-free way (a `build.mjs` - that writes one standalone HTML page), then publish it from thread storage: - `bb playgrounds publish --playground-file /absolute/path/figure.html`. Read the - `playgrounds` skill's "Anatomy figures" section for the card's `read` and `key` - actions and for binding the model's inputs to playground parameters. - -## Quick figure — default - -For an ordinary request to explain how something works, build a **quick figure**. -This is the complete default workflow. Use the full Figure/Hero workflow below -only when the person explicitly asks to go deeper or requests that level of -craft. A complex subject alone does not select full mode. - -- **Start visibly.** Before tools, tell the person in one sentence which - mechanism you are making interactive. Never ask a clarifying question: choose - a useful ordinary case, state a necessary assumption, and proceed. If the - build is still running after a minute, give a short factual progress update. -- **Budget about 3 minutes to a working published page.** Record the start time. - Spend roughly the first 30 seconds choosing the mechanism and starter, the - next two minutes building, and the remaining time checking and publishing. - This is a target, not a claim about elapsed time. If it runs long, report that - honestly and reduce decorative scope; keep the mechanism and controls working. -- **One mechanism, at most 40 parts.** Draw only the parts needed to show cause - and effect, with one model driving movement and readouts. There is no minimum - part count. Use SVG for the drawing, inline CSS and JavaScript for the page, - and no WebGL, canvas or 3D runtime. Give the object volume and purposeful - detail, but do not add screws or ornament to meet a density quota. -- **Reuse the nearest bundled example when it fits.** Choose from the examples - listed below; read and adapt its source in the thread-storage copy. Keep just - the useful mechanism and SVG pieces. The smallest SVG example is the starting - point when its controls and motion fit. If none fits, use the kit's standalone - page helpers directly. Do not build or read every example. -- **Make the explanation true and operable.** Use a simple, explicitly described - model; verify uncertain facts from relevant sources rather than inventing - precise numbers. Give each exposed control a visible effect in the mechanism - or readout, with a visible label, keyboard support and reduced-motion behavior. - Bind model inputs to playground parameters as described in the `playgrounds` - skill so the person and agent can operate the same model. -- **Check the working page once, then publish.** Open it, operate every exposed - control with `scripts/drive.mjs`, and compare before/after screenshots to catch - dead controls. Check the figure and controls fit a 390 px column, and fix any - broken behavior or unreadable content. This mode has **no audit pass**: skip - geometry audits, line checks, 4× detail sweeps and shader/3D checks. Do not - inherit those requirements from the full-mode sections or references below. - Publish the standalone HTML with `--playground-file`, emit its directive, - and give one concise takeaway with Anatomy credit. - -The examples and kit are shared by both modes. The dense-detail requirements, -full workflow, craft verification, WebGL and 3D sections below apply **only to -full mode**. Consult references only for an API or technique you need; a reference -must not expand a quick figure into a full production build. - -## What it makes - -This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts. - -Eight complete examples ship with the skill. Start from the closest one that fits; -quick figures keep only the SVG parts needed for their one mechanism. Each framework-free example is built with `node examples//build.mjs`, which writes a standalone HTML page next to the script; add `--light` for the light theme. - -- `examples/dial-indicator/` is the smallest: a dial gauge on a stand, about 300 lines in all. -- `examples/desk-computer/` is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths. -- `examples/arcade-cabinet/` is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order. `node build.mjs --audit` proves the geometry. -- `examples/raptor-engine/` is a rocket engine hanging in a test stand. Its round parts come from `kit/lathe.mjs` and its pipes and harness from `kit/tube.mjs`. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, and `node build.mjs --audit` (`kit/audit.mjs`) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades. -- `examples/ripple-tank/` is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (`model.mjs`); a frequency change travels out from the dippers through a history texture. -- `examples/test-rig/` is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths. -- `examples/spot-plate/` is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer than `merge` runs together into one piece of glass. A fan of feeler blades sets `merge`, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts. -- `examples/turning-dial/` is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts. `stress.mjs` beside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance. - -For full mode, the test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering. - -## Full mode: the one idea that matters - -**The object is the explanation.** Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working *is* the concept. Here is how earlier figures mapped: - -| Concept | Object | -|---|---| -| One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells | -| A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn | -| Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes | -| A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails | -| A spring that presses and leans | A test rig with springs, a probe and a dial gauge | -| A height profile | A profile (contour) gauge pressed against the edge | -| Light that depends on facing | A small photo studio: key and fill lamps around a turntable | - -Two ways this fails, both seen in practice: - -- **Too abstract.** Stacked plates, floating slabs or labelled boxes don't read as anything. -- **Too flat and bland.** One big slab with a few lines on it has no volume, no parts and nothing to discover. - -The fix for both is the same: a recognisable machine, built from many small, precise, purposeful parts. - -**Every full-mode creation is highly detailed.** These density requirements do -not apply to quick figures. What scales with the full-mode size (workflow step 1) -is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing. -- **Read the real thing.** Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts. -- **Two looks.** At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm. -- **Purposeful, never noise.** Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops. -- **Check it last.** Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain. - -## Full-mode workflow - -Use this only for an explicit request to go deeper or for a full Figure, Hero or -Epic. Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there. - -1. **Size it before you start.** Tell the user which size the request is and roughly how long it will take, before any other work. - - **Figure**: one mechanism, 40–80 parts, SVG with at most one WebGL layer. About 30–90 minutes. Build it without asking. - - **Hero**: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick. - - **Epic**: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it. - - Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run `date` at the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left. -2. **Truth first.** Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like `rig.ts` in the test-rig example, and drive everything from it. -3. **Invent the object.** Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object. -4. **Parts list.** Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part. `references/craft.md` has a vocabulary of parts with recipes. -5. **Plan the world.** Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles `{x, y, w, d, r}` and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit with `fitProjection(points, width, height, { pad, azimuth })`. -6. **Build geometry once.** Precompute every static path in a geometry module with the kit: `slabOf`, `cylinder`, `extrude`, `planOutline`, `topTicks`, `knurl`, `coil`, `radialTicks` and so on. Round parts on any axis (bells, flanges, valves, actuators) come from `kit/lathe.mjs`, and pipes and cables from `kit/tube.mjs`. Moving parts are built once at a rest position and moved with `translateAlong`. Only parts whose shape changes are rebuilt per frame. -7. **Paint back to front, bottom to top.** Sort repeated parts with `depthOf`. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" in `references/craft.md`. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it into `A.recorder(P, { order, spring })`, let `A.settle(R, P)` measure true depth wherever two items overlap and set the keys, and end the build with `A.auditOrExit(R, P)`. Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (`references/craft.md`, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod from `G.rodOut`, recorded with `A.slide`. -8. **Frame it.** Put the figure in a card: `Fig n` at top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label. -9. **Make it live.** Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See `references/motion.md`. -10. **Verify like a critic.** The owner finds faults by zooming in, so you zoom in first. Read every PNG you make. - - Capture desktop, phone (390 wide), hover and several moments of motion with `scripts/capture.mjs`; use `scripts/drive.mjs` for sequences, the apart pose and the `--no-webgl` fallback. - - **Run the audit to zero.** With pipes, cables or round parts, `node build.mjs --audit` must end `audit passed`, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias. - - **Run the line check to zero.** The audit never sees line markup. `node scripts/lines.mjs page.html --out shots/lines.png` (and on the `-light` page, and in every resting pose with `drive.mjs`'s `["lines"]` action) must print `line ends: 0`: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG. - - **4× close-ups of every junction in both themes** (build `--light` too), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it. - - **Shaders:** a contact sheet slowed with `["slow", 0.05]` across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (`references/verify.md`, "Light-theme shaders"). - - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist: `references/verify.md`. - -## Setup - -The kit is in `kit/`: - -| File | Use it for | -|---|---| -| `kit/iso-kit.ts` | The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (`ISO_CSS`). Copy it into a TypeScript project. | -| `kit/iso-kit.mjs` | The same core compiled to plain ESM, for Node scripts. Import it directly. | -| `kit/iso.css` | `ISO_CSS` written out. Import it once and put `class="iso"` (plus `data-theme="light"` for light) on a wrapper or ``. | -| `kit/react/draw.tsx` | Server-safe React components: `Solid`, `Line`, `Wire`, `Dots`, `FaceText`, `IsoFigure`, `Plate`. | -| `kit/react/live.tsx` | `"use client"` hooks: `useReadout`, `useLoop`, `useEased`, `useInView`, `useStillness`, plus `spring` and `follow`. | -| `kit/lathe.mjs` | Round parts along any axis: `frameAlong`, `lathe` (smooth shading switches on by itself for any profile with a smooth knot), `solidOf`, `disc`, `ringBand`, `sphereOf`, `saddleOf`, `bandsOf` (one curved body cut into depth-sortable bands that share one surface, with one outline from `sidesOf`), visible arcs, ribs and bolt circles on round surfaces (`ribsOf` and `dotsOf` take `fade`, `ribsOf` takes `seams`), `rodOut` for a rod that slides out of a gland, and `fillet` for pipe routes. | -| `kit/tube.mjs` | `tubePieces` splits a 3D pipe or cable into depth-sortable chunks (with `breaks`, `gaps` under collars and clamps, elliptical end caps, and `rings` for a braid, corrugation or wrap that follows the cross-section through every joint), and `tubeSvg` draws one; `crossingsOf` finds where a route passes through a clamp, `runsOf` the drawn runs. Its `.tb-*` fills are in `ISO_CSS`. | -| `kit/audit.mjs` | Proves the geometry. `recorder(P, { order, spring })` collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; `settle(R, P)` ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (`overlapsOf`, `orderKeys`); `auditOrExit(R, P)` is the `--audit` hook that runs every check (`clearances`, `solidClearances`, `terminals`, `looseEnds`, `supports`, `folds`, `selfOverlaps`, `crowding` with slender solids as lines, `coverage` for stand-in shapes, `wobble`, `seats`, `depthOrder`) and exits non-zero on any problem. `A.slide` records a rod that slides out of a gland. `references/kit.md`, section 12. | -| `kit/gl.mjs` | The WebGL layer for shaders inside the drawing: `GLSL_ISO` (the camera in GLSL, including `rayOf` and `pixelOf`), `glCamera(P)`, `glLayer(...)`, and the JS envelopes `smooth`, `burst` and `settle`. For 3D figures, `GLSL_TURN` and `glslPose(name)` bring world points into a moving group and mask a front canvas with `coverOf`. | -| `kit/turn.mjs`, `kit/turn-build.mjs`, `kit/turn-audit.mjs` | The optional 3D mode. `turn-build.mjs` has `turning(P, { recorder })`: groups (turn, slide, hinge, free), live layers, convex parts with materials, and `T.build`. `turn.mjs` is the runtime: `TURN.mount`, the controller, `TURN.cover`, `TURN.poseUniforms` and `TURN_CSS`. `turn-audit.mjs` has `orbitOrExit`, the orbit audit over every turn angle and motion clip. `references/3d.md`. | -| `scripts/capture.mjs` | Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with `node`. | -| `scripts/clip.mjs` | One-step local MP4/GIF replay of an HTML/Anatomy card; see the Playgrounds skill’s “Make a clip” section. | -| `scripts/drive.mjs` | Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; `--no-webgl` and `--preload`. | -| `scripts/lines.mjs` | The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with `--out`; exits 1 on any. | -| `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. | -| `scripts/turn-check.mjs`, `scripts/turn-bench.mjs` | 3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. | - -Pick the path that fits the project: - -- **Standalone HTML, a static SVG, a blog post or any non-React site.** Write a Node build script like `examples/dial-indicator/build.mjs`. It imports `kit/iso-kit.mjs`, assembles strings with `solidSvg`, `lineSvg`, `dotsSvg`, `wireSvg` and `faceTextSvg`, wraps them with `figureSvg` and `plateHtml`, and writes the page with `pageHtml({ script: kitScript() + live })`, or `kitScript() + glScript() + live` when it has a shader. For a lone `.svg` file use `figureSvg({ standalone: "dark" | "light" })`, which embeds the styles. -- **React or Next.js.** Copy `kit/` into the project (for example `components/iso/`). Keep geometry in plain `.ts` modules, build the static art in a server component with the `draw.tsx` components, and pass it as element props to one `"use client"` live component that moves things. `references/react.md` explains the split and a trap with large pages. - -## Full-mode craft rules (short form) - -These are the rules that separate a crafted figure from a diagram. `references/craft.md` gives the full reasoning and recipes. - -- **Solids, not wireframes.** Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines. -- **Let the kit shade.** `extrude` returns four side tones by facing plus a lighter top. Keep it for anything with height, and use `flat` only for floors and inlays thinner than 1 unit. -- **Tone hierarchy.** Base and feet are `lo`, structure is `mid` and the subject is `hi`. Exactly one accent is `lit` (white in dark, black in light): the part the reader should watch. Grooves are `faint`, minor ticks `lo`, major ticks `mid` and the zero mark `hi`. Use colour only when colour is the subject, such as red, green and blue channels. -- **Small precise detail, never noise.** Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part. -- **Lines resolve.** Every line ends on something: a face, another line or a terminal dot. Connectors are `Wire`s whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air. -- **No pipe or cable passes through anything.** Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad, `G.saddleOf`, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot. -- **Every crossing is drawn in true depth order.** Where two items overlap on screen, the nearer is drawn on top, proved by `A.settle` and the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias. -- **Curved surfaces shade smoothly, never in a staircase.** Mark curved knots smooth (`[s, r, 1]`) and `lathe` shades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile with `smooth: { slope }`, cut long bodies with `G.bandsOf`, and use `G.sphereOf` for balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (`inner`). -- **A texture ends on a real edge or fades.** Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved: `ribsOf` and `dotsOf` take `fade` (draw faded ribs with `fadedSvg`), a rib row ends on its seams (`ribsOf(…, { seams: true })`), a seam or weld on a box wraps its corners (`k.sideSeam`, as the slab's `inner`), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out with `fadeLineSvg`, and a row whose features face up (holes on top of a ring) is seen all round (`all: true`). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint: `tubePieces(…, { rings })`, never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, marked `free`. -- **Shaders never pop or cut off.** Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below). -- **Things on faces fit them.** Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn. -- **Volume.** Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted. -- **Composition.** Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide. - -## Full mode: WebGL inside the drawing - -Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded: - -- **Three layers.** A back SVG holds what the effect covers, the canvas holds the effect (`pointer-events: none`), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides. -- **World space, same camera.** Build every shader point from `onFloor`, `onWallX` and `onWallY`, or march the pixel's ray `rayOf(vb) + t·towardViewer()`. Size everything in world units times `uK`. Anything lying on a plane registers exactly with that plane's SVG lines. -- **Nothing pops.** Every uniform follows an envelope with an attack and a decay (the kit's `burst` for events, `settle` for levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker. -- **No hard cut-offs.** Every term reaches zero before any `if`, bound or canvas edge, and before any cell edge of a cell noise. -- **Seen through an opening, masked twice.** Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (`references/webgl.md`, section 5). -- **Blending.** Use `tonemap()` and dither. In dark, a canvas that only adds light uses premultiplied output with `mix-blend-mode: screen`, and one that must also darken (the ripple tank's water) stays normal-blended. -- **Light theme ships, and its shaders are designed for paper.** Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke". - - What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth). - - Hot bodies follow one blackbody ink table shared with the fallback. - - Ink only grows with light: fade the finished ink once, never one term at a time. - - Ink ends at each stroke's inner edge. - - Measure ink per frame. `references/webgl.md`, section 8, has the recipes and the faults reviewers found. -- **Restraint.** Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL. - -Read `references/webgl.md` before writing any shader. - -## Full mode: 3D figures that turn (beta) - -3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art. - -It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine. - -- **Live parts are the kit's own picture.** Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth `a − θ`. -- **Order comes from separating planes.** Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame. -- **Contacts are flush, tangent or rim-on-flat.** Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub. -- **Live tubes bend in near-horizontal planes or inside fittings, and never point along the view.** A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in `gaps`. That holds at every angle and in every pose. -- **Materials remap the ramp.** Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing. -- **Free groups break off without a jump.** Coaxial parts are fixed pictures with turning textures. -- **Shaders follow the motion.** Anchor them to a group with `controller.anchor` or `toGroup`, occlude a front canvas with `coverOf`, and keep world effects (smoke, falling sparks, oil on the bench) out of group space. -- **Verify with `--audit`** (`turn audit passed`) and `scripts/turn-check.mjs` (`--fidelity`, `--order`, `--pops`, `--lines`, `--perf`). Iterate with `--quick --only=…`. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background. - -Read `references/3d.md` first. - -## References - -Read these as you need them. Their dense-detail and audit requirements are for -full mode; quick figures use only the API or technique needed for the mechanism: - -- `references/craft.md`: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing a full-mode figure. -- `references/kit.md`: the API, with signatures and examples. -- `references/motion.md`: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React. -- `references/react.md`: the React/Next.js server/client split, element props and the large-payload key-warning trap. -- `references/verify.md`: the `capture.mjs` and `drive.mjs` commands, the review checklist (including shaders) and common faults. -- `references/webgl.md`: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them. -- `references/walkthrough.md`: how the test rig was designed, step by step, from concept to code. -- `references/3d.md`: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits. +## Choose the mode + +For an ordinary “how does this work?” or “explain this to my team” request, use +**quick figure** below. A complicated topic alone does not change the mode. +For an explicit “go deeper”, full Figure, Hero or Epic request, read +[references/full-mode.md](references/full-mode.md) and follow that workflow. +Do not load the full-mode reference for a quick figure. + +## Quick figure + +Make one mechanism the person can operate, in about **3 minutes**. This is a +working explanation, with no minimum detail count and **at most 40 parts**. +Use SVG, inline CSS and JavaScript; no canvas, WebGL, 3D runtime or audit pass. + +1. **Show progress before tools.** Say which mechanism you will make interactive. + Never ask a clarifying question: choose an ordinary case, state any necessary + assumption, and proceed. Give another factual update after a minute if needed. +2. **Choose and build in one short pass.** Spend about 30 seconds choosing the + mechanism and starter, then write the working page. Aim for 8–16 essential + parts, one or two useful inputs, and one readout from the same model. Give + the parts volume and visible contacts; omit decorative hardware. Choose a + fixed view that exposes the action, rather than comparing camera angles. + Verify uncertain facts when needed; label simplifying assumptions. +3. **Use thread storage, including in Personal threads.** Create a fresh copy: + `figure_dir=$(mktemp -d "$BB_THREAD_STORAGE/figure-XXXXXX")`, then + `cp -R /. "$figure_dir/"`. Build and edit the copy, never + the installed skill. No checkout or Git command is needed. + Reuse the nearest bundled example if its mechanism/control fits: + `examples/dial-indicator/build.mjs` is the smallest SVG example (drag/keys, + readout and moving parts); `examples/desk-computer/build.mjs` has typing and + on/off state. Read only the useful source, not every example. If neither + fits, write a small SVG mechanism directly or use `kit/iso-kit.mjs` with + `pageHtml`/`plateHtml`; consult `references/kit.md` only for an API you need. + Examples ship as sources: run `node examples//build.mjs` before opening + their generated page. The other examples and craft instructions are listed + in the full-mode reference for deeper requests. +4. **Bind the controls once.** Use the parameter contract below. Every exposed + input must change the mechanism or an explanatory readout. Keep native input + keyboard behavior. Reduced motion must still draw each new state immediately: + disabling animation must not disable Next, Reset, Replay or other actions. +5. **Publish, then check the actual card once.** Publish the standalone page with + `bb playgrounds publish --playground-file /absolute/path/figure.html`. + Before emitting the returned directive, use `scripts/drive.mjs` on the actual + frame at `$BB_SERVER_URL/api/v1/plugins/playgrounds/http/frame?thread=$BB_THREAD_ID&id=`. + Check at 390 px, operate every exposed input with real click/drag/key actions, + and compare before/after screenshots. The published bridge normalizes inputs; + a local file preview without it does not prove the card's controls work. + Fix broken controls or unreadable content and republish only if needed. Skip + geometry audits, line checks, 4× sweeps, alternate cameras and decorative + revisions. Once the mechanism and controls work, emit the final directive + and one concise takeaway with Ryan/Anatomy credit. If over target, say so + honestly; do not keep polishing before showing the working explanation. + +## Parameters for a quick figure + +The published frame supplies `window.playground`. Declare each input before +using it, then let both UI events and agent commands update the same model: + +```js +const P = playground.params; +P.define("position", {type: "number", min: 0, max: 100, step: 1, default: 0}); +P.define("mode", {type: "select", options: [ + {value: "free", label: "Free"}, {value: "loaded", label: "Loaded"} +], default: "free"}); +P.define("enabled", {type: "toggle", default: true}); +const input = document.querySelector("#position"); +input.min = P.spec("position").min; input.max = P.spec("position").max; +input.step = P.spec("position").step; +input.addEventListener("input", () => P.set("position", Number(input.value))); +function render() { + const {position, mode, enabled} = P.all(); + input.value = position; + // Draw the mechanism and its readout from these values. +} +P.on(render); render(); +``` + +- Numeric inputs default to **0..1** unless explicit `min`/`max` are supplied. + Match every slider and step sequence's complete intended range; use `step: 1` + for an index. Read the shared value after setting it rather than keeping a + separate unbounded index. +- Choices use `options: [{value, label}, ...]` with a default matching a value. + Bare string/number options also work. Free text uses `type: "text"`; a boolean + uses `type: "toggle"` (`string`/`boolean` are accepted aliases). +- `P.set(name, value)` and `P.set({name: value})` both work. `P.get(name)` reads + a value; `P.on(render)` redraws on changes, including `setParams` from bb. + Keep parameter declarations and bounds beside the HTML controls to avoid drift. +- Test choices beyond the first two, both range endpoints, and a complete + advance/reset cycle when exposed. Confirm the figure or explanatory text + changes, not just a button's color or selected value. + +The card's `read` and `setParams` actions let the person and agent operate the +same model. Use the Playgrounds skill only for another API or form you need; +this quick path does not require reading its unrelated component sets. diff --git a/plugins/playgrounds/skills/how-it-works/references/full-mode.md b/plugins/playgrounds/skills/how-it-works/references/full-mode.md new file mode 100644 index 00000000..bede74b1 --- /dev/null +++ b/plugins/playgrounds/skills/how-it-works/references/full-mode.md @@ -0,0 +1,176 @@ +# Full Figure, Hero and Epic + +Read this only for an explicit request to go deeper or for a full Figure, Hero or Epic. +Paths in commands and code below are relative to the copied skill root. The original +full workflow is retained here; none of its density or audit requirements apply to +the default quick figure. + +## What it makes + +This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts. + +Eight complete examples ship with the skill. Start from the closest one that fits; +quick figures keep only the SVG parts needed for their one mechanism. Each framework-free example is built with `node examples//build.mjs`, which writes a standalone HTML page next to the script; add `--light` for the light theme. + +- `examples/dial-indicator/` is the smallest: a dial gauge on a stand, about 300 lines in all. +- `examples/desk-computer/` is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths. +- `examples/arcade-cabinet/` is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order. `node build.mjs --audit` proves the geometry. +- `examples/raptor-engine/` is a rocket engine hanging in a test stand. Its round parts come from `kit/lathe.mjs` and its pipes and harness from `kit/tube.mjs`. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, and `node build.mjs --audit` (`kit/audit.mjs`) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades. +- `examples/ripple-tank/` is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (`model.mjs`); a frequency change travels out from the dippers through a history texture. +- `examples/test-rig/` is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths. +- `examples/spot-plate/` is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer than `merge` runs together into one piece of glass. A fan of feeler blades sets `merge`, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts. +- `examples/turning-dial/` is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts. `stress.mjs` beside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance. + +For full mode, the test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering. + +## Full mode: the one idea that matters + +**The object is the explanation.** Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working *is* the concept. Here is how earlier figures mapped: + +| Concept | Object | +|---|---| +| One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells | +| A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn | +| Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes | +| A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails | +| A spring that presses and leans | A test rig with springs, a probe and a dial gauge | +| A height profile | A profile (contour) gauge pressed against the edge | +| Light that depends on facing | A small photo studio: key and fill lamps around a turntable | + +Two ways this fails, both seen in practice: + +- **Too abstract.** Stacked plates, floating slabs or labelled boxes don't read as anything. +- **Too flat and bland.** One big slab with a few lines on it has no volume, no parts and nothing to discover. + +The fix for both is the same: a recognisable machine, built from many small, precise, purposeful parts. + +**Every full-mode creation is highly detailed.** These density requirements do +not apply to quick figures. What scales with the full-mode size (workflow step 1) +is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing. +- **Read the real thing.** Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts. +- **Two looks.** At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm. +- **Purposeful, never noise.** Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops. +- **Check it last.** Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain. + +## Full-mode workflow + +Use this only for an explicit request to go deeper or for a full Figure, Hero or +Epic. Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there. + +1. **Size it before you start.** Tell the user which size the request is and roughly how long it will take, before any other work. + - **Figure**: one mechanism, 40–80 parts, SVG with at most one WebGL layer. About 30–90 minutes. Build it without asking. + - **Hero**: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick. + - **Epic**: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it. + - Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run `date` at the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left. +2. **Truth first.** Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like `rig.ts` in the test-rig example, and drive everything from it. +3. **Invent the object.** Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object. +4. **Parts list.** Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part. `references/craft.md` has a vocabulary of parts with recipes. +5. **Plan the world.** Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles `{x, y, w, d, r}` and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit with `fitProjection(points, width, height, { pad, azimuth })`. +6. **Build geometry once.** Precompute every static path in a geometry module with the kit: `slabOf`, `cylinder`, `extrude`, `planOutline`, `topTicks`, `knurl`, `coil`, `radialTicks` and so on. Round parts on any axis (bells, flanges, valves, actuators) come from `kit/lathe.mjs`, and pipes and cables from `kit/tube.mjs`. Moving parts are built once at a rest position and moved with `translateAlong`. Only parts whose shape changes are rebuilt per frame. +7. **Paint back to front, bottom to top.** Sort repeated parts with `depthOf`. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" in `references/craft.md`. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it into `A.recorder(P, { order, spring })`, let `A.settle(R, P)` measure true depth wherever two items overlap and set the keys, and end the build with `A.auditOrExit(R, P)`. Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (`references/craft.md`, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod from `G.rodOut`, recorded with `A.slide`. +8. **Frame it.** Put the figure in a card: `Fig n` at top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label. +9. **Make it live.** Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See `references/motion.md`. +10. **Verify like a critic.** The owner finds faults by zooming in, so you zoom in first. Read every PNG you make. + - Capture desktop, phone (390 wide), hover and several moments of motion with `scripts/capture.mjs`; use `scripts/drive.mjs` for sequences, the apart pose and the `--no-webgl` fallback. + - **Run the audit to zero.** With pipes, cables or round parts, `node build.mjs --audit` must end `audit passed`, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias. + - **Run the line check to zero.** The audit never sees line markup. `node scripts/lines.mjs page.html --out shots/lines.png` (and on the `-light` page, and in every resting pose with `drive.mjs`'s `["lines"]` action) must print `line ends: 0`: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG. + - **4× close-ups of every junction in both themes** (build `--light` too), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it. + - **Shaders:** a contact sheet slowed with `["slow", 0.05]` across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (`references/verify.md`, "Light-theme shaders"). + - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist: `references/verify.md`. + +## Setup + +The kit is in `kit/`: + +| File | Use it for | +|---|---| +| `kit/iso-kit.ts` | The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (`ISO_CSS`). Copy it into a TypeScript project. | +| `kit/iso-kit.mjs` | The same core compiled to plain ESM, for Node scripts. Import it directly. | +| `kit/iso.css` | `ISO_CSS` written out. Import it once and put `class="iso"` (plus `data-theme="light"` for light) on a wrapper or ``. | +| `kit/react/draw.tsx` | Server-safe React components: `Solid`, `Line`, `Wire`, `Dots`, `FaceText`, `IsoFigure`, `Plate`. | +| `kit/react/live.tsx` | `"use client"` hooks: `useReadout`, `useLoop`, `useEased`, `useInView`, `useStillness`, plus `spring` and `follow`. | +| `kit/lathe.mjs` | Round parts along any axis: `frameAlong`, `lathe` (smooth shading switches on by itself for any profile with a smooth knot), `solidOf`, `disc`, `ringBand`, `sphereOf`, `saddleOf`, `bandsOf` (one curved body cut into depth-sortable bands that share one surface, with one outline from `sidesOf`), visible arcs, ribs and bolt circles on round surfaces (`ribsOf` and `dotsOf` take `fade`, `ribsOf` takes `seams`), `rodOut` for a rod that slides out of a gland, and `fillet` for pipe routes. | +| `kit/tube.mjs` | `tubePieces` splits a 3D pipe or cable into depth-sortable chunks (with `breaks`, `gaps` under collars and clamps, elliptical end caps, and `rings` for a braid, corrugation or wrap that follows the cross-section through every joint), and `tubeSvg` draws one; `crossingsOf` finds where a route passes through a clamp, `runsOf` the drawn runs. Its `.tb-*` fills are in `ISO_CSS`. | +| `kit/audit.mjs` | Proves the geometry. `recorder(P, { order, spring })` collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; `settle(R, P)` ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (`overlapsOf`, `orderKeys`); `auditOrExit(R, P)` is the `--audit` hook that runs every check (`clearances`, `solidClearances`, `terminals`, `looseEnds`, `supports`, `folds`, `selfOverlaps`, `crowding` with slender solids as lines, `coverage` for stand-in shapes, `wobble`, `seats`, `depthOrder`) and exits non-zero on any problem. `A.slide` records a rod that slides out of a gland. `references/kit.md`, section 12. | +| `kit/gl.mjs` | The WebGL layer for shaders inside the drawing: `GLSL_ISO` (the camera in GLSL, including `rayOf` and `pixelOf`), `glCamera(P)`, `glLayer(...)`, and the JS envelopes `smooth`, `burst` and `settle`. For 3D figures, `GLSL_TURN` and `glslPose(name)` bring world points into a moving group and mask a front canvas with `coverOf`. | +| `kit/turn.mjs`, `kit/turn-build.mjs`, `kit/turn-audit.mjs` | The optional 3D mode. `turn-build.mjs` has `turning(P, { recorder })`: groups (turn, slide, hinge, free), live layers, convex parts with materials, and `T.build`. `turn.mjs` is the runtime: `TURN.mount`, the controller, `TURN.cover`, `TURN.poseUniforms` and `TURN_CSS`. `turn-audit.mjs` has `orbitOrExit`, the orbit audit over every turn angle and motion clip. `references/3d.md`. | +| `scripts/capture.mjs` | Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with `node`. | +| `scripts/clip.mjs` | One-step local MP4/GIF replay of an HTML/Anatomy card; see the Playgrounds skill’s “Make a clip” section. | +| `scripts/drive.mjs` | Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; `--no-webgl` and `--preload`. | +| `scripts/lines.mjs` | The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with `--out`; exits 1 on any. | +| `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. | +| `scripts/turn-check.mjs`, `scripts/turn-bench.mjs` | 3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. | + +Pick the path that fits the project: + +- **Standalone HTML, a static SVG, a blog post or any non-React site.** Write a Node build script like `examples/dial-indicator/build.mjs`. It imports `kit/iso-kit.mjs`, assembles strings with `solidSvg`, `lineSvg`, `dotsSvg`, `wireSvg` and `faceTextSvg`, wraps them with `figureSvg` and `plateHtml`, and writes the page with `pageHtml({ script: kitScript() + live })`, or `kitScript() + glScript() + live` when it has a shader. For a lone `.svg` file use `figureSvg({ standalone: "dark" | "light" })`, which embeds the styles. +- **React or Next.js.** Copy `kit/` into the project (for example `components/iso/`). Keep geometry in plain `.ts` modules, build the static art in a server component with the `draw.tsx` components, and pass it as element props to one `"use client"` live component that moves things. `references/react.md` explains the split and a trap with large pages. + +## Full-mode craft rules (short form) + +These are the rules that separate a crafted figure from a diagram. `references/craft.md` gives the full reasoning and recipes. + +- **Solids, not wireframes.** Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines. +- **Let the kit shade.** `extrude` returns four side tones by facing plus a lighter top. Keep it for anything with height, and use `flat` only for floors and inlays thinner than 1 unit. +- **Tone hierarchy.** Base and feet are `lo`, structure is `mid` and the subject is `hi`. Exactly one accent is `lit` (white in dark, black in light): the part the reader should watch. Grooves are `faint`, minor ticks `lo`, major ticks `mid` and the zero mark `hi`. Use colour only when colour is the subject, such as red, green and blue channels. +- **Small precise detail, never noise.** Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part. +- **Lines resolve.** Every line ends on something: a face, another line or a terminal dot. Connectors are `Wire`s whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air. +- **No pipe or cable passes through anything.** Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad, `G.saddleOf`, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot. +- **Every crossing is drawn in true depth order.** Where two items overlap on screen, the nearer is drawn on top, proved by `A.settle` and the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias. +- **Curved surfaces shade smoothly, never in a staircase.** Mark curved knots smooth (`[s, r, 1]`) and `lathe` shades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile with `smooth: { slope }`, cut long bodies with `G.bandsOf`, and use `G.sphereOf` for balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (`inner`). +- **A texture ends on a real edge or fades.** Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved: `ribsOf` and `dotsOf` take `fade` (draw faded ribs with `fadedSvg`), a rib row ends on its seams (`ribsOf(…, { seams: true })`), a seam or weld on a box wraps its corners (`k.sideSeam`, as the slab's `inner`), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out with `fadeLineSvg`, and a row whose features face up (holes on top of a ring) is seen all round (`all: true`). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint: `tubePieces(…, { rings })`, never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, marked `free`. +- **Shaders never pop or cut off.** Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below). +- **Things on faces fit them.** Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn. +- **Volume.** Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted. +- **Composition.** Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide. + +## Full mode: WebGL inside the drawing + +Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded: + +- **Three layers.** A back SVG holds what the effect covers, the canvas holds the effect (`pointer-events: none`), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides. +- **World space, same camera.** Build every shader point from `onFloor`, `onWallX` and `onWallY`, or march the pixel's ray `rayOf(vb) + t·towardViewer()`. Size everything in world units times `uK`. Anything lying on a plane registers exactly with that plane's SVG lines. +- **Nothing pops.** Every uniform follows an envelope with an attack and a decay (the kit's `burst` for events, `settle` for levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker. +- **No hard cut-offs.** Every term reaches zero before any `if`, bound or canvas edge, and before any cell edge of a cell noise. +- **Seen through an opening, masked twice.** Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (`references/webgl.md`, section 5). +- **Blending.** Use `tonemap()` and dither. In dark, a canvas that only adds light uses premultiplied output with `mix-blend-mode: screen`, and one that must also darken (the ripple tank's water) stays normal-blended. +- **Light theme ships, and its shaders are designed for paper.** Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke". + - What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth). + - Hot bodies follow one blackbody ink table shared with the fallback. + - Ink only grows with light: fade the finished ink once, never one term at a time. + - Ink ends at each stroke's inner edge. + - Measure ink per frame. `references/webgl.md`, section 8, has the recipes and the faults reviewers found. +- **Restraint.** Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL. + +Read `references/webgl.md` before writing any shader. + +## Full mode: 3D figures that turn (beta) + +3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art. + +It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine. + +- **Live parts are the kit's own picture.** Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth `a − θ`. +- **Order comes from separating planes.** Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame. +- **Contacts are flush, tangent or rim-on-flat.** Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub. +- **Live tubes bend in near-horizontal planes or inside fittings, and never point along the view.** A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in `gaps`. That holds at every angle and in every pose. +- **Materials remap the ramp.** Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing. +- **Free groups break off without a jump.** Coaxial parts are fixed pictures with turning textures. +- **Shaders follow the motion.** Anchor them to a group with `controller.anchor` or `toGroup`, occlude a front canvas with `coverOf`, and keep world effects (smoke, falling sparks, oil on the bench) out of group space. +- **Verify with `--audit`** (`turn audit passed`) and `scripts/turn-check.mjs` (`--fidelity`, `--order`, `--pops`, `--lines`, `--perf`). Iterate with `--quick --only=…`. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background. + +Read `references/3d.md` first. + +## References + +Read these as you need them. Their dense-detail and audit requirements are for +full mode; quick figures use only the API or technique needed for the mechanism: + +- `references/craft.md`: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing a full-mode figure. +- `references/kit.md`: the API, with signatures and examples. +- `references/motion.md`: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React. +- `references/react.md`: the React/Next.js server/client split, element props and the large-payload key-warning trap. +- `references/verify.md`: the `capture.mjs` and `drive.mjs` commands, the review checklist (including shaders) and common faults. +- `references/webgl.md`: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them. +- `references/walkthrough.md`: how the test rig was designed, step by step, from concept to code. +- `references/3d.md`: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits. diff --git a/plugins/playgrounds/skills/playgrounds/SKILL.md b/plugins/playgrounds/skills/playgrounds/SKILL.md index 9b456442..c8dbae73 100644 --- a/plugins/playgrounds/skills/playgrounds/SKILL.md +++ b/plugins/playgrounds/skills/playgrounds/SKILL.md @@ -237,10 +237,20 @@ Unknown names throw, including in `setParams`; object updates validate every nam before changing any value. Read inputs in the preview/model and redraw with `params.on(update)`, then call `update()` once for the initial view. +For programmatic inputs, `params.define("position", {type: "number", min: 0, +max: 100, step: 1, default: 0})` declares the full numeric range; omitted bounds +mean **0..1**. A sequence index needs its actual last index as `max`, not an +unbounded local counter. Choices use `{type: "select", options: [{value: "a", +label: "A"}, {value: "b", label: "B"}], default: "a"}`; bare string/number +options also work, with strict value types. Free text uses `type: "text"` and +booleans use `type: "toggle"` (`string` and `boolean` are accepted aliases). +Keep HTML bounds and choices consistent with these declarations. Test the last +choice and both endpoints in the published frame, whose bridge validates values. + Publishing checks JavaScript bindings as well as markup: it rejects literal references to undeclared parameters and controls with no consumer, with names and repair instructions. This is a static check, not proof that a handler works: -click every control and confirm the preview changes before publishing, including +click every control and confirm the preview changes before showing the card, including when names or controls are generated dynamically. **Driving it.** Every card with these controls exposes the same actions: @@ -489,7 +499,10 @@ the `how-it-works` skill and follow its **quick figure** workflow: one mechanism at most 40 parts, SVG only, no audit pass, and about 3 minutes to a working card. Start from the nearest bundled example when it fits. Show progress, choose reasonable defaults without clarifying questions, and leave the full Figure/Hero -workflow for an explicit request to "go deeper". Build the framework-free way +workflow in `how-it-works/references/full-mode.md` for an explicit request to +"go deeper". The quick entrypoint includes the parameter contract; avoid loading +unrelated component sets or full-mode references while making a quick figure. +Build the framework-free way (a `build.mjs` that writes one standalone HTML page), then publish that page: ```sh diff --git a/plugins/playgrounds/widget.test.ts b/plugins/playgrounds/widget.test.ts index ddbd5651..82f92603 100644 --- a/plugins/playgrounds/widget.test.ts +++ b/plugins/playgrounds/widget.test.ts @@ -352,6 +352,34 @@ it("updates mock-up outputs with both setter forms and rejects unknown inputs at dom.window.close(); }); +it("drives programmatic choices and text/boolean aliases through user and agent input", async () => { + const { dom } = frame(''); + dom.window.document.querySelector("button")!.click(); + const output = () => JSON.parse(dom.window.document.querySelector("output")!.textContent!); + expect(output()).toEqual({ mode: "far", caption: "Changed", enabled: true }); + expect(dom.window.document.querySelector("button")!.getAttribute("aria-pressed")).toBe("true"); + expect(await command(dom.window, "setParams", [{ mode: "near", caption: "From agent", enabled: false }])).toMatchObject({ ok: true }); + expect(output()).toEqual({ mode: "near", caption: "From agent", enabled: false }); + expect(dom.window.document.querySelector("button")!.getAttribute("aria-pressed")).toBe("false"); + dom.window.close(); +}); + +it("preserves typed choices and explicit numeric bounds through the card bridge", async () => { + const { dom } = frame(''); + expect(await command(dom.window, "setParams", [{ step: 4, size: 8, mode: "b" }])).toMatchObject({ ok: true }); + expect(JSON.parse(dom.window.document.querySelector("output")!.textContent!)).toEqual({ step: 4, size: 8, mode: "b" }); + expect(await command(dom.window, "setParams", [{ step: 12, size: "8" }])).toMatchObject({ ok: true }); + expect(JSON.parse(dom.window.document.querySelector("output")!.textContent!)).toEqual({ step: 4, size: 2, mode: "b" }); + dom.window.close(); +}); + it("keeps native and non-button toggles operable with visible wrapping labels", async () => { const { dom } = frame('
'); const { window } = dom; @@ -377,3 +405,32 @@ it("keeps native and non-button toggles operable with visible wrapping labels", } window.close(); }); + +it("does not apply a standalone frame's queued save messages as remote state", async () => { + const { dom } = frame(''); + const { window } = dom; + if (window.document.readyState !== "complete") await new Promise((resolve) => window.addEventListener("load", resolve)); + window.document.querySelector("button")!.click(); + const output = window.document.querySelector("output")!; + expect(output.textContent).toBe("true"); + window.dispatchEvent(new window.MessageEvent("message", { + source: window, data: { source: WIDGET_MESSAGE_SOURCE, type: "state", state: { params: { enabled: false } }, base: 0 }, + })); + expect(output.textContent).toBe("true"); + expect(await command(window, "setParams", [{ enabled: false }])).toMatchObject({ ok: true }); + expect(output.textContent).toBe("false"); + window.close(); +}); + +it("still applies shared state sent by an embedded card's parent", () => { + const { dom } = frame(''); + const host = new JSDOM(""); + Object.defineProperty(dom.window, "parent", { value: host.window }); + dom.window.dispatchEvent(new dom.window.MessageEvent("message", { + source: host.window, data: { source: WIDGET_MESSAGE_SOURCE, type: "state", state: { params: { enabled: true } }, version: 1 }, + })); + expect(dom.window.document.querySelector("output")!.textContent).toBe("true"); + dom.window.close(); host.window.close(); +}); diff --git a/plugins/playgrounds/widget.ts b/plugins/playgrounds/widget.ts index 0ab18387..562f2bce 100644 --- a/plugins/playgrounds/widget.ts +++ b/plugins/playgrounds/widget.ts @@ -167,7 +167,8 @@ ${CONTROLS_RUNTIME.replace("/*SETS*/", CHART_RUNTIME + SETS_RUNTIME)} const message = event.data; if (event.source !== parent || !message || message.source !== SOURCE) return; if (message.type === "theme") { theme = message.theme; apply(theme); notify(listeners, theme); } - if (message.type === "state") { state = message.state; if (typeof message.version === "number") version = message.version; notify(stateListeners, state); } + // Standalone previews post to themselves; their local save is already applied. + if (message.type === "state" && parent !== window) { state = message.state; if (typeof message.version === "number") version = message.version; notify(stateListeners, state); } if (message.type === "font" && typeof message.family === "string" && message.data instanceof ArrayBuffer) { try { const face = new FontFace(message.family, message.data, { weight: "100 900" }); document.fonts.add(face); face.load().catch(() => {}); } catch {} }