@botiverse/porthole 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,11 +20,11 @@
20
20
  <span>Web</span><div style="height: 12px; width: 100%; background: var(--color-chart-1)"></div><span>412</span>
21
21
  <span>iOS</span><div style="height: 12px; width: 77%; background: var(--color-text-disabled)"></div><span>318</span>
22
22
  <span>Docs</span><div style="height: 12px; width: 16%; background: var(--color-text-disabled)"></div><span>64</span>
23
- </div>`};var sn={layout:"where",theme:"where",storage:"interactive",errors:"interactive",silent:"sandbox",network:"sandbox","inline-assets":"sandbox",dialogs:"sandbox",print:"sandbox",downloads:"sandbox",forms:"sandbox","link-schemes":"sandbox",windows:"sandbox",devices:"sandbox",workers:"sandbox",sound:"sandbox",address:"sandbox",shell:"sandbox"},ln=e=>e.mode==="artifact"?"page":e.mode==="mcp-app"?"view":"widget",ye=(e,t)=>F(e,t).length>0||(e.egress.anyHttps??[]).some(n=>n===t);function we(e,t){if(e.mode==="url")return[];let n=pe(e),o=new Set(D(e)),r=new Set(Y(e)?.globals??[]),a=ln(e),i=[],s=(p,y)=>{y!==null&&i.push({id:p,text:y,topic:sn[p]})};s("layout",e.layout.kind==="fill"?"It is as tall as the window: the page scrolls itself, and position: fixed and sticky work.":o.has("resize")?`Its height follows its content, at most ${e.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`:"Its height is fixed by the app: keep it short, or let it scroll inside.");let c=e.theme.documents??"declared";s("theme",e.mode!=="artifact"||c==="always"?"It runs in the person's light or dark theme.":c==="never"?"It always shows in light: the app doesn't theme it, so style it yourself; prefers-color-scheme may report the reader's system setting, so don't rely on it.":`It shows in light, as you style it; to follow the person's light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.`);let l="a blocked script, style or image just doesn't load and a blocked fetch rejects";s("silent",o.has("reportError")?e.errors==="agent"?`What the sandbox blocks shows nothing on the page: ${l}. Each block comes back to you as an error, so check them.`:e.errors==="host"?`What the sandbox blocks shows nothing on the page: ${l}. The app is told of each block, but you aren't: test what you write.`:`What the sandbox blocks shows nothing on the page: ${l}. Each block is reported to the app.`:"What the sandbox blocks fails silently: a blocked script, style or image just doesn't load, a blocked fetch rejects, and nothing shows an error."),s("network",t);let f=p=>(e.egress.anyHttps??[]).some(y=>y===p),k=["img","font","media"].filter(p=>!f(p));if(k.some(p=>ye(e,p))||ye(e,"style")||F(e,"script").length>0||F(e,"connect").length>0||k.length<3){let p=k.length===0?null:k.every(A=>!ye(e,A))?"images, icons, fonts and sound go inline (data: URLs or SVG)":"images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)",y=ye(e,"style")?"other CSS goes in a <style>":"CSS goes in a <style>",C=[p,y].filter(A=>A!==null).join("; ");s("inline-assets",`${C.charAt(0).toUpperCase()}${C.slice(1)}.`)}n.includes("allow-modals")||(s("dialogs","alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs."),s("print","window.print() does nothing.")),n.includes("allow-downloads")||s("downloads",`Downloads don't work: <a download>, data: and blob: links, saves from script.${o.has("downloadFile")?" To give the person a file, ask the app (ui/download-file; with the App SDK, app.downloadFile).":""}`),n.includes("allow-forms")||s("forms",'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.'),s("link-schemes",`mailto:, tel: and sms: links open nothing: show the address or number as text${r.has("copyText")?" (with a copy button)":""}.`),n.includes("allow-popups")||s("windows",r.has("openLink")||o.has("openLink")?"window.open and target=_blank open no window (window.open returns null or throws): links open in a new tab through the app (openLink; a click on <a href> does it).":"Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws)."),s("devices","Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work."),s("workers","Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone."),s("sound","Sound (and video with sound) plays only from a click: call play() in its handler."),s("address",e.isolation==="opaque"?`The ${a}'s address is about:srcdoc: it has no query string or hash to read.`:`The ${a}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`),s("shell",`document.write and document.open throw; nested frames (iframe, object, embed) are removed; full screen and the clipboard are closed${r.has("copyText")?" (copy buttons call copyText)":""}.`),s("storage",e.isolation==="opaque"?`State isn't saved: localStorage works but lasts only while the ${a} is on screen, and a reload or another device starts fresh. Keep what matters in the ${a}'s code.`:e.storage?.scope!==void 0?`localStorage is kept for ${e.storage.scope}, in this browser; another browser or device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`:e.storage?.shared===void 0?`localStorage is kept for this ${a} in this browser; another device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`:`localStorage is shared by every ${a} in this ${e.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${a}'s own ("budget:filters", not "filters"), or ${a}s overwrite each other. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`);let d=e.limits.maxErrorReports,g=(e.capabilities.reportError?.gates??[{rateLimit:{perMinute:10}}]).find(p=>typeof p=="object"),u=g===void 0?"":` At most ${g.rateLimit.perMinute} a minute.`;return s("errors",o.has("reportError")?e.errors==="agent"?`Errors: what your code throws, and anything the sandbox blocks, comes back to you (at most ${d} a run), so you can fix it.${u}`:e.errors==="host"?`Errors: what your code throws, and anything the sandbox blocks, goes to the app (at most ${d} a run), not to you: test what you write.${u}`:`Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${d} a run).${u}`:"Errors: nothing reports what your code throws, to you or the app: test what you write."),i}function pt(e){return e<1e6?`${Math.floor(e/1e3)} KB`:`${Math.round(e/1048576*10)/10} MB`}var ft=/--[a-z0-9]+(?:-[a-z0-9]+)*/gu;function gt(e){let t=Object.keys(e.theme.variables),n=(...a)=>a.every(i=>t.includes(i)),o=new Set(D(e)),r=new Set(Y(e)?.globals??[]);return{config:e,noun:e.mode==="artifact"?"page":e.mode==="mcp-app"?"view":"widget",has:n,when:a=>n(...a.match(ft)??[])?a:null,names:(a,i=[])=>t.filter(s=>s.startsWith(a)&&!i.some(c=>s.startsWith(c))).join(", "),declared:a=>o.has(a),global:a=>r.has(a)}}var je=(...e)=>e.filter(t=>t!==null).join(`
23
+ </div>`};var sn={layout:"where",theme:"where",storage:"interactive",errors:"interactive",silent:"sandbox",network:"sandbox","inline-assets":"sandbox",dialogs:"sandbox",print:"sandbox",downloads:"sandbox",forms:"sandbox","link-schemes":"sandbox",windows:"sandbox",devices:"sandbox",workers:"sandbox",sound:"sandbox",address:"sandbox",shell:"sandbox"},ln=e=>e.mode==="artifact"?"page":e.mode==="mcp-app"?"view":"widget",ye=(e,t)=>F(e,t).length>0||(e.egress.anyHttps??[]).some(n=>n===t);function we(e,t){if(e.mode==="url")return[];let n=pe(e),o=new Set(D(e)),r=new Set(Y(e)?.globals??[]),a=ln(e),i=[],s=(p,y)=>{y!==null&&i.push({id:p,text:y,topic:sn[p]})};s("layout",e.layout.kind==="fill"?"It is as tall as the viewer, not the window: the page scrolls itself, and position: fixed and sticky work.":o.has("resize")?`Its height follows its content, at most ${e.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`:"Its height is fixed by the app: keep it short, or let it scroll inside.");let c=e.theme.documents??"declared";s("theme",e.mode!=="artifact"||c==="always"?"It runs in the person's light or dark theme.":c==="never"?"It always shows in light: the app doesn't theme it, so style it yourself; prefers-color-scheme may report the reader's system setting, so don't rely on it.":`It shows in light, as you style it; to follow the person's light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.`);let l="a blocked script, style or image just doesn't load and a blocked fetch rejects";s("silent",o.has("reportError")?e.errors==="agent"?`What the sandbox blocks shows nothing on the page: ${l}. Each block comes back to you as an error, so check them.`:e.errors==="host"?`What the sandbox blocks shows nothing on the page: ${l}. The app is told of each block, but you aren't: test what you write.`:`What the sandbox blocks shows nothing on the page: ${l}. Each block is reported to the app.`:"What the sandbox blocks fails silently: a blocked script, style or image just doesn't load, a blocked fetch rejects, and nothing shows an error."),s("network",t);let f=p=>(e.egress.anyHttps??[]).some(y=>y===p),k=["img","font","media"].filter(p=>!f(p));if(k.some(p=>ye(e,p))||ye(e,"style")||F(e,"script").length>0||F(e,"connect").length>0||k.length<3){let p=k.length===0?null:k.every(A=>!ye(e,A))?"images, icons, fonts and sound go inline (data: URLs or SVG)":"images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)",y=ye(e,"style")?"other CSS goes in a <style>":"CSS goes in a <style>",C=[p,y].filter(A=>A!==null).join("; ");s("inline-assets",`${C.charAt(0).toUpperCase()}${C.slice(1)}.`)}n.includes("allow-modals")||(s("dialogs","alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs."),s("print","window.print() does nothing.")),n.includes("allow-downloads")||s("downloads",`Downloads don't work: <a download>, data: and blob: links, saves from script.${o.has("downloadFile")?" To give the person a file, ask the app (ui/download-file; with the App SDK, app.downloadFile).":""}`),n.includes("allow-forms")||s("forms",'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.'),s("link-schemes",`mailto:, tel: and sms: links open nothing: show the address or number as text${r.has("copyText")?" (with a copy button)":""}.`),n.includes("allow-popups")||s("windows",r.has("openLink")||o.has("openLink")?"window.open and target=_blank open no window (window.open returns null or throws): links open in a new tab through the app (openLink; a click on <a href> does it).":"Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws)."),s("devices","Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work."),s("workers","Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone."),s("sound","Sound (and video with sound) plays only from a click: call play() in its handler."),s("address",e.isolation==="opaque"?`The ${a}'s address is about:srcdoc: it has no query string or hash to read.`:`The ${a}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`),s("shell",`document.write and document.open throw; nested frames (iframe, object, embed) are removed; full screen and the clipboard are closed${r.has("copyText")?" (copy buttons call copyText)":""}.`),s("storage",e.isolation==="opaque"?`State isn't saved: localStorage works but lasts only while the ${a} is on screen, and a reload or another device starts fresh. Keep what matters in the ${a}'s code.`:e.storage?.scope!==void 0?`localStorage is kept for ${e.storage.scope}, in this browser; another browser or device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`:e.storage?.shared===void 0?`localStorage is kept for this ${a} in this browser; another device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`:`localStorage is shared by every ${a} in this ${e.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${a}'s own ("budget:filters", not "filters"), or ${a}s overwrite each other. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`);let d=e.limits.maxErrorReports,g=(e.capabilities.reportError?.gates??[{rateLimit:{perMinute:10}}]).find(p=>typeof p=="object"),u=g===void 0?"":` At most ${g.rateLimit.perMinute} a minute.`;return s("errors",o.has("reportError")?e.errors==="agent"?`Errors: what your code throws, and anything the sandbox blocks, comes back to you (at most ${d} a run), so you can fix it.${u}`:e.errors==="host"?`Errors: what your code throws, and anything the sandbox blocks, goes to the app (at most ${d} a run), not to you: test what you write.${u}`:`Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${d} a run).${u}`:"Errors: nothing reports what your code throws, to you or the app: test what you write."),i}function pt(e){return e<1e6?`${Math.floor(e/1e3)} KB`:`${Math.round(e/1048576*10)/10} MB`}var ft=/--[a-z0-9]+(?:-[a-z0-9]+)*/gu;function gt(e){let t=Object.keys(e.theme.variables),n=(...a)=>a.every(i=>t.includes(i)),o=new Set(D(e)),r=new Set(Y(e)?.globals??[]);return{config:e,noun:e.mode==="artifact"?"page":e.mode==="mcp-app"?"view":"widget",has:n,when:a=>n(...a.match(ft)??[])?a:null,names:(a,i=[])=>t.filter(s=>s.startsWith(a)&&!i.some(c=>s.startsWith(c))).join(", "),declared:a=>o.has(a),global:a=>r.has(a)}}var je=(...e)=>e.filter(t=>t!==null).join(`
24
24
  `).replace(/\n{3,}/gu,`
25
25
 
26
- `).trim(),yt={script:"scripts",style:"styles",font:"fonts",img:"images",media:"audio and video",connect:"data (fetch, WebSocket)"},wt="any https site",dn=(e,t)=>[...ue(e,t)?[wt]:[],...F(e,t)];function cn(e){let n=["script","style","font","img","media","connect"].map(i=>[i,dn(e,i)]).filter(([,i])=>i.length>0);if(n.length===0)return"Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";let o=new Map;for(let[i,s]of n)for(let c of s)o.set(c,[...o.get(c)??[],i]);let r=new Map;for(let[i,s]of o){let c=s.map(f=>yt[f]),l=c.length===1?c[0]??"":`${c.slice(0,-1).join(", ")} and ${c.at(-1)}`;r.set(l,[...r.get(l)??[],i])}return`Network: only ${[...r].map(([i,s])=>`${i} from ${s.join(", ")}`).join("; ")}. Anything else fails.`}var un=e=>e.mode==="artifact"&&(e.theme.documents??"declared")==="declared",_e=e=>F(e,"script").length>0;function pn(e){let{config:t,noun:n}=e,{layout:o,limits:r}=t,a=n==="page"?"Pages":n==="view"?"Views":"Widgets",i=t.guide.toolName===void 0?"":` (${t.guide.toolName})`,s=t.mode==="artifact"?"What you pass: a whole HTML document (<!doctype html>, <head>, <body>). It is written into its frame once all of it has arrived, then its scripts run in order.":t.mode==="mcp-app"?"What you pass: an MCP Apps view, a whole HTML document that talks to its host through the official App SDK (@modelcontextprotocol/ext-apps) or the protocol's own messages.":"What you pass: SVG that starts with <svg, or an HTML fragment: no <!doctype>, <html>, <head> or <body>. Order it styles, then markup, then scripts: people see the styled markup as you write it, and scripts run once all of it is written, in order, each external one loaded before the next runs.",c=we(t,cn(t)),l=u=>c.find(p=>p.id===u)?.text??"",f=o.kind==="fill",k=t.theme.documents==="never",m=o.narrowWidthPx+20,v=Math.round(13*o.minWidthPx/m*10)/10,d=Math.round(13*400/m*10)/10,g=He(t.theme.variables["--font-text-md-size"]??"14px","light").replace(/^(\d+(?:\.\d+)?)px$/u,"$1 px");return je(`# ${a}${i}`,"",s,"",`Where it runs: ${f?`in a sandboxed frame that fills the viewer, about ${o.narrowWidthPx} px wide on a phone (${o.minWidthPx} on a small one) and as wide as the window on a desktop`:`in a sandboxed frame as wide as the column it sits in, about ${o.narrowWidthPx} px on a phone (${o.minWidthPx} on a small one) and ${o.wideWidthPx} on a desktop`}; the same ${n} is read on both, so never write for one of them. Design for ${o.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${f?"window's innerWidth, again on resize":"box's clientWidth, again from a ResizeObserver"}). ${l("layout")} ${l("theme")} It runs again each time it is shown${_e(t)?": pin library versions":""}.`,"","Sizes that read on a phone:",e.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${g} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`),`- An SVG scales with the frame: its text shows at font-size \xD7 frame width \xF7 viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${m} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${v} to ${d} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`,`- The frame never scrolls sideways: nothing fixed wider than ${o.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`,"- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, \u2026).","- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",f?null:"- No position: fixed: the frame grows with its content, so a fixed element covers it.","","The sandbox:",...c.filter(u=>u.topic==="sandbox").map(u=>`- ${u.text}`),`Keep it under ${pt(r.maxBytes)}.`,_e(t)?`
27
- Libraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time.`:null,"",k?`Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${n} its own background.`:un(t)?`Colors, for a ${n} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`:`Colors: every color from these variables, never hard-coded, so light and dark both work. The ${n}'s own background is transparent (it sits on the app's surface).`,e.names("--color-background-")===""?null:`- Surfaces: ${e.names("--color-background-")}`,e.names("--color-text-")===""?null:`- Text: ${e.names("--color-text-")}`,e.names("--color-border-")===""?null:`- Borders: ${e.names("--color-border-")}${e.names("--color-ring-")===""?"":`; focus rings: ${e.names("--color-ring-")}`}`,e.names("--color-accent")===""?null:`- Accent: ${[e.when("--color-accent (a button)"),e.when("--color-accent-soft (a selected item's fill)"),e.when("in a figure, --color-chart-1 (topic chart)")].filter(u=>u!==null).join("; ")}`,e.names("--font-")===""?null:`- Type: ${e.names("--font-")}`,e.names("--border-")===""&&e.names("--shadow-")===""?null:`- Shape: ${[e.names("--border-"),e.names("--shadow-")].filter(u=>u!=="").join(", ")}`,mt(e)===""?null:`- Also: ${mt(e)}`,"",be(e,q.stats)?`Cards that read in both themes, and wrap on a phone:
26
+ `).trim(),yt={script:"scripts",style:"styles",font:"fonts",img:"images",media:"audio and video",connect:"data (fetch, WebSocket)"},wt="any https site",dn=(e,t)=>[...ue(e,t)?[wt]:[],...F(e,t)];function cn(e){let n=["script","style","font","img","media","connect"].map(i=>[i,dn(e,i)]).filter(([,i])=>i.length>0);if(n.length===0)return"Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";let o=new Map;for(let[i,s]of n)for(let c of s)o.set(c,[...o.get(c)??[],i]);let r=new Map;for(let[i,s]of o){let c=s.map(f=>yt[f]),l=c.length===1?c[0]??"":`${c.slice(0,-1).join(", ")} and ${c.at(-1)}`;r.set(l,[...r.get(l)??[],i])}return`Network: only ${[...r].map(([i,s])=>`${i} from ${s.join(", ")}`).join("; ")}. Anything else fails.`}var un=e=>e.mode==="artifact"&&(e.theme.documents??"declared")==="declared",_e=e=>F(e,"script").length>0;function pn(e){let{config:t,noun:n}=e,{layout:o,limits:r}=t,a=n==="page"?"Pages":n==="view"?"Views":"Widgets",i=t.guide.toolName===void 0?"":` (${t.guide.toolName})`,s=t.mode==="artifact"?"What you pass: a whole HTML document (<!doctype html>, <head>, <body>). It is written into its frame once all of it has arrived, then its scripts run in order.":t.mode==="mcp-app"?"What you pass: an MCP Apps view, a whole HTML document that talks to its host through the official App SDK (@modelcontextprotocol/ext-apps) or the protocol's own messages.":"What you pass: SVG that starts with <svg, or an HTML fragment: no <!doctype>, <html>, <head> or <body>. Order it styles, then markup, then scripts: people see the styled markup as you write it, and scripts run once all of it is written, in order, each external one loaded before the next runs.",c=we(t,cn(t)),l=u=>c.find(p=>p.id===u)?.text??"",f=o.kind==="fill",k=t.theme.documents==="never",m=o.narrowWidthPx+20,v=Math.round(13*o.minWidthPx/m*10)/10,d=Math.round(13*400/m*10)/10,g=He(t.theme.variables["--font-text-md-size"]??"14px","light").replace(/^(\d+(?:\.\d+)?)px$/u,"$1 px");return je(`# ${a}${i}`,"",s,"",`Where it runs: ${f?`in a sandboxed frame that fills the app's viewer (often smaller than the window: toolbars and side panels take room), about ${o.narrowWidthPx} px wide on a phone (${o.minWidthPx} on a small one) and wider on a desktop`:`in a sandboxed frame as wide as the column it sits in, about ${o.narrowWidthPx} px on a phone (${o.minWidthPx} on a small one) and ${o.wideWidthPx} on a desktop`}; the same ${n} is read on both, so never write for one of them. Design for ${o.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${f?"frame's own innerWidth, again on resize":"box's clientWidth, again from a ResizeObserver"}). ${l("layout")} ${l("theme")} It runs again each time it is shown${_e(t)?": pin library versions":""}.`,"","Sizes that read on a phone:",e.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${g} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`),`- An SVG scales with the frame: its text shows at font-size \xD7 frame width \xF7 viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${m} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${v} to ${d} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`,`- The frame never scrolls sideways: nothing fixed wider than ${o.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`,"- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, \u2026).","- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",f?null:"- No position: fixed: the frame grows with its content, so a fixed element covers it.","","The sandbox:",...c.filter(u=>u.topic==="sandbox").map(u=>`- ${u.text}`),`Keep it under ${pt(r.maxBytes)}.`,_e(t)?`
27
+ Libraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time.`:null,"",k?`Colors: these variables hold the app's palette; use them, or colors of your own. Give the ${n} its own background.`:un(t)?`Colors, for a ${n} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`:`Colors: every color from these variables, never hard-coded, so light and dark both work. The ${n}'s own background is transparent (it sits on the app's surface).`,e.names("--color-background-")===""?null:`- Surfaces: ${e.names("--color-background-")}`,e.names("--color-text-")===""?null:`- Text: ${e.names("--color-text-")}`,e.names("--color-border-")===""?null:`- Borders: ${e.names("--color-border-")}${e.names("--color-ring-")===""?"":`; focus rings: ${e.names("--color-ring-")}`}`,e.names("--color-accent")===""?null:`- Accent: ${[e.when("--color-accent (a button)"),e.when("--color-accent-soft (a selected item's fill)"),e.when("in a figure, --color-chart-1 (topic chart)")].filter(u=>u!==null).join("; ")}`,e.names("--font-")===""?null:`- Type: ${e.names("--font-")}`,e.names("--border-")===""&&e.names("--shadow-")===""?null:`- Shape: ${[e.names("--border-"),e.names("--shadow-")].filter(u=>u!=="").join(", ")}`,mt(e)===""?null:`- Also: ${mt(e)}`,"",be(e,q.stats)?`Cards that ${k?"":"read in both themes, and "}wrap on a phone:
28
28
  ${q.stats}
29
29
  `:null,e.when("Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes."),k?null:`A theme change updates the variables in place and fires window "${t.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`,t.mode==="inline"?"Buttons and inputs already match the app (its borders, radius, colors and focus ring, 44 px tall): use them as they are, or restyle them with your own CSS.":null,"","Diagrams (boxes and arrows): show the mechanism asked about (what calls what, where data goes), only the parts the ask names; label each arrow with what moves.","- Top to bottom, at most 2 boxes a row, in the phone viewBox; the title in HTML above. Too much for one glance on a phone: make two.","- SVG doesn't wrap: break lines yourself. A box is its longest line + 24 wide (13 px text takes about 7 units a character, a 14 px bold name 8.5: a 136-wide box holds 16); a name and a line, 52 tall; 16 between boxes.",e.when("- Boxes fill none, stroke --color-border-secondary; at most one stands out: stroke --color-chart-1, 2 wide, fill --color-chart-1 at fill-opacity 0.12. Arrows --color-text-tertiary, in the gaps, never across a box, their labels beside them. Every <text> sets fill: names --color-text-primary, the rest --color-text-secondary."),be(e,q.diagram)?`
30
30
  ${q.diagram}`:null)}function mt(e){let t=["--color-background-","--color-text-","--color-border-","--color-ring-","--color-accent","--color-chart-","--font-","--border-","--shadow-"];return Object.keys(e.config.theme.variables).filter(n=>!t.some(o=>n.startsWith(o))).join(", ")}function be(e,t){return e.has(...t.match(ft)??[])&&!/https?:\/\//u.test(t)}function mn(e){let{config:t}=e,n=Object.keys(t.theme.variables).filter(a=>/^--color-chart-\d+$/u.test(a)),o=Object.keys(t.theme.variables).filter(a=>/^--color-chart-seq-\d+$/u.test(a)),r=_e(t)?`Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${n[0]??"--color-text-primary"}").trim()${t.theme.documents==="never"?"":`, redraw on window "${t.theme.event}"`}, and override their palette and white background.`:"No chart library loads here: draw the SVG yourself, from a script, as below.";return je(`# Charts (a chart is a ${e.noun})`,"","Pick the form first: a trend over time \u2192 line; compare categories \u2192 horizontal bars; x against y \u2192 scatter; one number \u2192 a big number, not a chart; a few numbers \u2192 a table, not a chart.","","The look:",'- The title states the finding, with its number ("Failed builds fell from 9 to 2 a week"), not the topic; a line under it says what is measured, in what unit.',e.when("- Grey first: the data in a quiet grey (--color-text-disabled), one accent (--color-chart-1) on what the title is about. More hues only for series that differ in kind and must each be found."),"- Values on the marks or a light axis, not both; label only the points the finding rests on. Bars start at zero.","- Write the numbers once, as rows, and draw marks, labels and readout from them.","","Size: draw it at the frame's width, 1 unit = 1 px: read the box's clientWidth, draw an <svg> that wide with 12 px text, and again from a ResizeObserver. Its text stays 12 px on every screen; keep x labels 44 px apart. No script: the phone viewBox (topic widget). Bars need no SVG: an HTML grid row per category.","",r,"",n.length===0?null:"Colors:",n.length===0?null:`- Series of different kinds: ${n[0]} \u2026 ${n.at(-1)}, in this order, never cycled, at most ${n.length} (fold the rest into "Other"); a series keeps its color when a filter hides others.`,o.length===0?null:`- Magnitude (a heatmap): ${o[0]} \u2026 ${o.at(-1)}, 1 nearest zero.${e.has("--color-chart-neg","--color-chart-mid","--color-chart-pos")?" Above or below a midpoint: --color-chart-neg, --color-chart-mid, --color-chart-pos.":""}`,e.when("- Never a rainbow. Status (good, bad) uses --color-text-success, --color-text-warning, --color-text-danger with a word, never as a series color."),e.when("- Text is never in a series color: --color-text-primary or --color-text-secondary; the mark beside it carries identity."),"",e.when("Marks: 2 px lines, bars with a 2 px gap; grid lines --color-border-tertiary, 1 px; one y axis (two scales: two charts); the legend in HTML."),"",`Reading values: a readout line in HTML that a tap on a column fills (each column a hit area the plot's height), the latest values shown before any tap; a "Table" toggle for the numbers.`,be(e,q.line)?`
@@ -33,7 +33,7 @@ ${q.line}`:null,be(e,q.bars)?`
33
33
  Bars in HTML, under a title like the line chart's:
34
34
  ${q.bars}`:null)}function j(e,t){let n=[];e.includes("confirm")&&n.push(`The app shows the person ${t} first, and nothing happens unless they confirm.`),e.includes("user-activation")&&n.push("Call it from a click (it needs one).");for(let o of e)typeof o=="object"&&n.push(`At most ${o.rateLimit.perMinute} a minute.`);return n.join(" ")}var hn={sendPrompt:(e,t)=>e.global("sendPrompt")?`- sendPrompt(text): asks to send text to you as the person's message (at most ${e.config.limits.maxPromptChars.toLocaleString("en-US")} characters). Use it for "explain this point", "try these settings". ${j(t,"the whole text")} It returns a promise: true if it was sent.`:`- Messages: the MCP Apps request ui/message (with the App SDK, app.sendMessage) asks to send text to you as the person's message. ${j(t,"the whole text")}`,openLink:(e,t)=>e.global("openLink")?`- openLink(url): asks to open an http(s) link in a new tab; a click on an <a href> does the same for you. ${j(t,"the link")} It returns a promise: true if it opened.`:`- Links: the MCP Apps request ui/open-link (with the App SDK, app.openLink) asks to open an http(s) link in a new tab. ${j(t,"the link")}`,downloadFile:(e,t)=>`- Downloads: the MCP Apps request ui/download-file (with the App SDK, app.downloadFile). ${j(t,"the file")}`,callTool:(e,t)=>`- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${j(t,"the call")}`,readData:(e,t)=>`- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${j(t,"the request")}`,setModelContext:(e,t)=>`- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${j(t,"it")}`,copyText:(e,t)=>e.global("copyText")?`- copyText(text): copies text to the person's clipboard (at most ${2e4.toLocaleString("en-US")} characters), and the app shows them what was copied. Use it for every copy button: nothing else in the ${e.noun} can write the clipboard. ${j(t,"the text")} It returns a promise: true if it was copied.`:`- Copying: the request porthole/copy-text with { text } (at most ${2e4.toLocaleString("en-US")} characters) copies text to the person's clipboard, and the app shows them what was copied; nothing else in the ${e.noun} can write the clipboard. ${j(t,"the text")}`,annotate:e=>`- Comments: the person may point at parts of the ${e.noun} to comment on them. Give headings and the parts that matter stable ids (id="revenue"), so comments stay on them when the ${e.noun} is written again.`,reportError:e=>`- ${we(e.config,"").find(t=>t.id==="errors")?.text??""}`,resize:()=>""};function fn(e){let{config:t,noun:n}=e,o=D(t).map(l=>{let f=t.capabilities[l]?.guide;return f===void 0?hn[l](e,K(t,l)):`- ${f}`}).filter(l=>l!==""),r=we(t,""),a=r.find(l=>l.id==="storage"),i=a===void 0?null:`- ${a.text}`,s=e.declared("reportError")?null:`- ${r.find(l=>l.id==="errors")?.text??""}`,c=o.length===0?"- It can only draw: it has no way to send anything to you or the app.":null;return je(`# Interactive ${n}s`,"",...o,s,c,i)}var ht={widget:pn,chart:mn,interactive:fn};function bt(e,t){let n=gt(e),o=e.guide.topics??{};return(t??De).map(r=>{if(Object.hasOwn(ht,r))return ht[r](n);let a=o[r];if(a===void 0)throw new RangeError(`No guide topic ${JSON.stringify(r)}`);return a}).join(`
35
35
 
36
- `)}function xt(e){let t=gt(e),{layout:n}=e,o=e.mode==="artifact"?"Show a whole HTML page you write: a report, a document, a small app.":e.mode==="mcp-app"?"Show an MCP Apps view.":"Render an SVG or an HTML fragment you write, shown inline: charts, diagrams, explainers, small interactive tools.",r=[...new Set(["script","style","font","img","media","connect"].flatMap(m=>F(e,m)))],a=["img","media","font"].filter(m=>ue(e,m)).map(m=>yt[m]),i=a.length===0?"":`${a.length===1?a[0]:`${a.slice(0,-1).join(", ")} and ${a.at(-1)}`} from ${wt}`,s=r.length===0&&i===""?"with no network (nothing loads from outside)":r.length===0?`loading only ${i}`:`loading only from ${r.join(", ")}${i===""?"":`, and ${i}`}`,c=D(e).flatMap(m=>{let v=B(m);return v.global===void 0||!t.global(v.global)?[]:v.global==="sendPrompt"?["sendPrompt(text) asks the person to send text to you as their message."]:v.global==="openLink"?["openLink(url) asks the person to open a link."]:[]}),l=e.guide.guideToolName===void 0?"":` Read ${e.guide.guideToolName} once before your first ${t.noun}${e.mode==="inline"?" (topic chart before your first chart)":""}.`,f=n.kind==="fill"?`It fills the viewer in a sandbox about ${n.narrowWidthPx} px wide on a phone, from the same code on every screen`:`It runs in a sandbox about ${n.narrowWidthPx} px wide on a phone and ${n.wideWidthPx} on a desktop, from the same code${t.declared("resize")?" (its height follows the content)":""}`,k=e.theme.documents==="never"?"always in light (the app doesn't theme it)":"in the person's light or dark theme through CSS variables";return`${o} ${f}, ${k}, ${s}. ${c.join(" ")}${l} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu," ")}var gn=/^(https?):\/\/([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}))?$/u,vt=/^\d{1,3}(\.\d{1,3}){3}$/u,kt={http:"80",https:"443"};function xe(e){let t=gn.exec(e);if(t===null)return null;let[,n="",o="",r]=t;return{scheme:n,host:o,port:r??kt[n]??""}}var yn=/^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;function wn(e){return typeof e=="string"&&e.length<=300&&yn.test(e)}function St(e){return wn(e)?null:e==="null"?`the host page's origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)`:`${JSON.stringify(e)} is not an exact origin (scheme://host[:port], lowercase, no path)`}function bn(e,t){let n=e.toLowerCase().replace(/\.$/u,""),o=t.toLowerCase().replace(/\.$/u,"");if(n===o)return!0;if(vt.test(n)||vt.test(o)||n.startsWith("[")||o.startsWith("["))return!1;let r=n.split(".").toReversed(),a=o.split(".").toReversed(),i=0;for(;i<r.length&&i<a.length&&r[i]===a[i];)i+=1;return i>=2}function xn(e,t){let n=xe(e),o=/^(https?):\/\/\*\.([a-z0-9.-]+)(?::(\d{1,5}))?$/u.exec(t);if(n===null)return!1;if(o===null){let s=xe(t);return s!==null&&s.scheme===n.scheme&&s.host===n.host&&s.port===n.port}let[,r="",a="",i]=o;return r===n.scheme&&(i??kt[r])===n.port&&n.host.endsWith(`.${a}`)&&n.host.length>a.length+1}function Ct(e,t,n){let o=xe(e.origin),r;try{r=new URL(e.url)}catch{throw new TypeError(`The located URL ${JSON.stringify(e.url)} is not a URL`)}if(o===null||r.origin!==e.origin)throw new TypeError(`The located URL ${JSON.stringify(e.url)} is not on its origin ${JSON.stringify(e.origin)}`);if(!t.some(i=>xn(e.origin,i)))throw new TypeError(`The located origin ${e.origin} is not one of the config's content origins`);let a=xe(n);if(a!==null&&bn(o.host,a.host))throw new TypeError(`The located origin ${e.origin} may be the same site as the host (${n}): content must be served from a registrable domain the host uses for nothing else`)}var Tt=String.raw`if (declared("annotate")) {
36
+ `)}function xt(e){let t=gt(e),{layout:n}=e,o=e.mode==="artifact"?"Show a whole HTML page you write: a report, a document, a small app.":e.mode==="mcp-app"?"Show an MCP Apps view.":"Render an SVG or an HTML fragment you write, shown inline: charts, diagrams, explainers, small interactive tools.",r=[...new Set(["script","style","font","img","media","connect"].flatMap(m=>F(e,m)))],a=["img","media","font"].filter(m=>ue(e,m)).map(m=>yt[m]),i=a.length===0?"":`${a.length===1?a[0]:`${a.slice(0,-1).join(", ")} and ${a.at(-1)}`} from ${wt}`,s=r.length===0&&i===""?"with no network (nothing loads from outside)":r.length===0?`loading only ${i}`:`loading only from ${r.join(", ")}${i===""?"":`, and ${i}`}`,c=D(e).flatMap(m=>{let v=B(m);return v.global===void 0||!t.global(v.global)?[]:v.global==="sendPrompt"?["sendPrompt(text) asks the person to send text to you as their message."]:v.global==="openLink"?["openLink(url) asks the person to open a link."]:[]}),l=e.guide.guideToolName===void 0?"":` Call ${e.guide.guideToolName} once before your first ${t.noun}${e.mode==="inline"?" (topic chart before your first chart)":""}.`,f=n.kind==="fill"?`It fills the viewer in a sandbox about ${n.narrowWidthPx} px wide on a phone, from the same code on every screen`:`It runs in a sandbox about ${n.narrowWidthPx} px wide on a phone and ${n.wideWidthPx} on a desktop, from the same code${t.declared("resize")?" (its height follows the content)":""}`,k=e.theme.documents==="never"?"always in light (the app doesn't theme it)":"in the person's light or dark theme through CSS variables";return`${o} ${f}, ${k}, ${s}. ${c.join(" ")}${l} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu," ")}var gn=/^(https?):\/\/([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}))?$/u,vt=/^\d{1,3}(\.\d{1,3}){3}$/u,kt={http:"80",https:"443"};function xe(e){let t=gn.exec(e);if(t===null)return null;let[,n="",o="",r]=t;return{scheme:n,host:o,port:r??kt[n]??""}}var yn=/^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;function wn(e){return typeof e=="string"&&e.length<=300&&yn.test(e)}function St(e){return wn(e)?null:e==="null"?`the host page's origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)`:`${JSON.stringify(e)} is not an exact origin (scheme://host[:port], lowercase, no path)`}function bn(e,t){let n=e.toLowerCase().replace(/\.$/u,""),o=t.toLowerCase().replace(/\.$/u,"");if(n===o)return!0;if(vt.test(n)||vt.test(o)||n.startsWith("[")||o.startsWith("["))return!1;let r=n.split(".").toReversed(),a=o.split(".").toReversed(),i=0;for(;i<r.length&&i<a.length&&r[i]===a[i];)i+=1;return i>=2}function xn(e,t){let n=xe(e),o=/^(https?):\/\/\*\.([a-z0-9.-]+)(?::(\d{1,5}))?$/u.exec(t);if(n===null)return!1;if(o===null){let s=xe(t);return s!==null&&s.scheme===n.scheme&&s.host===n.host&&s.port===n.port}let[,r="",a="",i]=o;return r===n.scheme&&(i??kt[r])===n.port&&n.host.endsWith(`.${a}`)&&n.host.length>a.length+1}function Ct(e,t,n){let o=xe(e.origin),r;try{r=new URL(e.url)}catch{throw new TypeError(`The located URL ${JSON.stringify(e.url)} is not a URL`)}if(o===null||r.origin!==e.origin)throw new TypeError(`The located URL ${JSON.stringify(e.url)} is not on its origin ${JSON.stringify(e.origin)}`);if(!t.some(i=>xn(e.origin,i)))throw new TypeError(`The located origin ${e.origin} is not one of the config's content origins`);let a=xe(n);if(a!==null&&bn(o.host,a.host))throw new TypeError(`The located origin ${e.origin} may be the same site as the host (${n}): content must be served from a registrable domain the host uses for nothing else`)}var Tt=String.raw`if (declared("annotate")) {
37
37
  (() => {
38
38
  const LIMIT = { exact: 4000, quote: 300, context: 32, label: 120, css: 1000, marks: 500, boxes: 200 };
39
39
  const M = {
@@ -414,6 +414,8 @@ export const LIMIT_PROBES = [
414
414
  const display = await context.launch({
415
415
  config: inline(),
416
416
  content: `<button id="go">Play</button>${RECORDER}<script>
417
+ r.mediaBlocked = false;
418
+ document.addEventListener("securitypolicyviolation", (event) => { if (event.effectiveDirective === "media-src") r.mediaBlocked = true; });
417
419
  new Audio(${js(SILENCE)}).play().then(() => (r.unasked = "played"), (e) => (r.unasked = "refused " + e.name));
418
420
  document.getElementById("go").addEventListener("click", () => {
419
421
  new Audio(${js(SILENCE)}).play().then(() => { r.clicked = "played"; r.done = true; }, (e) => { r.clicked = "refused " + e.name; r.done = true; });
@@ -423,6 +425,10 @@ export const LIMIT_PROBES = [
423
425
  await settle(500);
424
426
  await click(display, "#go");
425
427
  const verdict = await recorded(display);
428
+ // The frame's own policy allows data: media: a violation for it is the page's (P058).
429
+ if (verdict.clicked !== "played" && verdict.mediaBlocked === true) {
430
+ context.inapplicable("the page's own CSP, which an opaque frame inherits, blocks data: media (media-src): opaque content here plays no inline sound, whatever its config");
431
+ }
426
432
  expect(verdict.clicked, "play() in a click's handler").toBe("played");
427
433
  context.measured(`play() without a click: ${String(verdict.unasked)}`);
428
434
  },
@@ -92,12 +92,12 @@ function widget(c) {
92
92
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
93
93
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
94
94
  const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
95
- return lines(`# ${title}${tool}`, "", what, "", `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", fills
95
+ return lines(`# ${title}${tool}`, "", what, "", `Where it runs: ${fills ? `in a sandboxed frame that fills the app's viewer (often smaller than the window: toolbars and side panels take room), about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and wider on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "frame's own innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", fills
96
96
  ? null
97
97
  : "- No position: fixed: the frame grows with its content, so a fixed element covers it.", "", "The sandbox:", ...stated.filter((limit) => limit.topic === "sandbox").map((limit) => `- ${limit.text}`), `Keep it under ${sizeWords(limits.maxBytes)}.`, loadsScripts(config)
98
98
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
99
99
  : null, "", themeless
100
- ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
100
+ ? `Colors: these variables hold the app's palette; use them, or colors of your own. Give the ${noun} its own background.`
101
101
  : optsIn(config)
102
102
  ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
103
103
  : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`, c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`, c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`, c.names("--color-border-") === ""
@@ -113,7 +113,7 @@ function widget(c) {
113
113
  .join("; ")}`, c.names("--font-") === "" ? null : `- Type: ${c.names("--font-")}`, c.names("--border-") === "" && c.names("--shadow-") === ""
114
114
  ? null
115
115
  : `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`, otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`, "", exampleFits(c, GUIDE_EXAMPLES.stats)
116
- ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
116
+ ? `Cards that ${themeless ? "" : "read in both themes, and "}wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
117
117
  : null, c.when(`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`), themeless
118
118
  ? null
119
119
  : `A theme change updates the variables in place and fires window "${config.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`, config.mode === "inline"
@@ -272,7 +272,7 @@ export function generateToolDescription(config) {
272
272
  });
273
273
  const read = config.guide.guideToolName === undefined
274
274
  ? ""
275
- : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
275
+ : ` Call ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
276
276
  const where = layout.kind === "fill"
277
277
  ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
278
278
  : `It runs in a sandbox about ${layout.narrowWidthPx} px wide on a phone and ${layout.wideWidthPx} on a desktop, from the same code${c.declared("resize") ? " (its height follows the content)" : ""}`;
@@ -71,7 +71,7 @@ export function manualLimits(config, network) {
71
71
  };
72
72
  // Where it runs (P065): a card that grows with its content, or the whole viewer.
73
73
  add("layout", config.layout.kind === "fill"
74
- ? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
74
+ ? "It is as tall as the viewer, not the window: the page scrolls itself, and position: fixed and sticky work."
75
75
  : declared.has("resize")
76
76
  ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
77
77
  : "Its height is fixed by the app: keep it short, or let it scroll inside.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -468,6 +468,8 @@ export const LIMIT_PROBES: readonly Probe[] = [
468
468
  const display = await context.launch({
469
469
  config: inline(),
470
470
  content: `<button id="go">Play</button>${RECORDER}<script>
471
+ r.mediaBlocked = false;
472
+ document.addEventListener("securitypolicyviolation", (event) => { if (event.effectiveDirective === "media-src") r.mediaBlocked = true; });
471
473
  new Audio(${js(SILENCE)}).play().then(() => (r.unasked = "played"), (e) => (r.unasked = "refused " + e.name));
472
474
  document.getElementById("go").addEventListener("click", () => {
473
475
  new Audio(${js(SILENCE)}).play().then(() => { r.clicked = "played"; r.done = true; }, (e) => { r.clicked = "refused " + e.name; r.done = true; });
@@ -477,6 +479,12 @@ export const LIMIT_PROBES: readonly Probe[] = [
477
479
  await settle(500);
478
480
  await click(display, "#go");
479
481
  const verdict = await recorded(display);
482
+ // The frame's own policy allows data: media: a violation for it is the page's (P058).
483
+ if (verdict.clicked !== "played" && verdict.mediaBlocked === true) {
484
+ context.inapplicable(
485
+ "the page's own CSP, which an opaque frame inherits, blocks data: media (media-src): opaque content here plays no inline sound, whatever its config",
486
+ );
487
+ }
480
488
  expect(verdict.clicked, "play() in a click's handler").toBe("played");
481
489
  context.measured(`play() without a click: ${String(verdict.unasked)}`);
482
490
  },
@@ -157,7 +157,7 @@ function widget(c: Context): string {
157
157
  "",
158
158
  what,
159
159
  "",
160
- `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
160
+ `Where it runs: ${fills ? `in a sandboxed frame that fills the app's viewer (often smaller than the window: toolbars and side panels take room), about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and wider on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "frame's own innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
161
161
  "",
162
162
  "Sizes that read on a phone:",
163
163
  c.when(
@@ -179,7 +179,7 @@ function widget(c: Context): string {
179
179
  : null,
180
180
  "",
181
181
  themeless
182
- ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
182
+ ? `Colors: these variables hold the app's palette; use them, or colors of your own. Give the ${noun} its own background.`
183
183
  : optsIn(config)
184
184
  ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
185
185
  : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
@@ -204,7 +204,7 @@ function widget(c: Context): string {
204
204
  otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`,
205
205
  "",
206
206
  exampleFits(c, GUIDE_EXAMPLES.stats)
207
- ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
207
+ ? `Cards that ${themeless ? "" : "read in both themes, and "}wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
208
208
  : null,
209
209
  c.when(
210
210
  `Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`,
@@ -445,7 +445,7 @@ export function generateToolDescription(config: PortholeConfig): string {
445
445
  const read =
446
446
  config.guide.guideToolName === undefined
447
447
  ? ""
448
- : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
448
+ : ` Call ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
449
449
  const where =
450
450
  layout.kind === "fill"
451
451
  ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
@@ -95,7 +95,7 @@ export function manualLimits(config: PortholeConfig, network: string): ManualLim
95
95
  add(
96
96
  "layout",
97
97
  config.layout.kind === "fill"
98
- ? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
98
+ ? "It is as tall as the viewer, not the window: the page scrolls itself, and position: fixed and sticky work."
99
99
  : declared.has("resize")
100
100
  ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
101
101
  : "Its height is fixed by the app: keep it short, or let it scroll inside.",