@botiverse/porthole 0.2.0 → 0.2.2
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.
- package/dist/conformance/entry.d.ts +8 -2
- package/dist/conformance/entry.js +23 -8
- package/dist/conformance/harness.js +4 -4
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/limits.js +6 -0
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/guide/guide.js +4 -4
- package/dist/guide/limits.js +1 -1
- package/dist/node/index.d.ts +11 -3
- package/dist/node/index.js +70 -10
- package/dist/server/host.d.ts +42 -8
- package/dist/server/host.js +35 -5
- package/dist/server/index.d.ts +3 -1
- package/dist/server/pages.js +15 -1
- package/dist/server/proxy.js +2 -0
- package/dist/server/server.js +9 -2
- package/dist/server/types.d.ts +15 -6
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/DECISIONS.md +1774 -0
- package/docs/DESIGN.md +1067 -0
- package/docs/OBJECTIVES.md +116 -0
- package/docs/PRINCIPLES.md +155 -0
- package/package.json +4 -3
- package/src/conformance/entry.ts +39 -10
- package/src/conformance/host-kit.ts +71 -28
- package/src/conformance/limits.ts +8 -0
- package/src/conformance/served.ts +34 -21
- package/src/guide/guide.ts +4 -4
- package/src/guide/limits.ts +1 -1
- package/src/node/index.ts +73 -10
- package/src/server/host.ts +84 -14
- package/src/server/index.ts +4 -0
- package/src/server/pages.ts +15 -1
- package/src/server/proxy.ts +2 -0
- package/src/server/server.ts +11 -2
- package/src/server/types.ts +18 -7
|
@@ -15,9 +15,15 @@ export interface EntryConformanceHost {
|
|
|
15
15
|
* The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
|
|
16
16
|
* `scope`: it draws `<main id="artifact">version 1</main>`.
|
|
17
17
|
*/
|
|
18
|
-
url(scope: string): string
|
|
18
|
+
url(scope: string): string | Promise<string>;
|
|
19
19
|
/** A clean address the signed-in viewer may not open. */
|
|
20
|
-
denied(scope: string): string
|
|
20
|
+
denied(scope: string): string | Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Addresses on the content domain that name nothing (a label the app never assigns, the bare
|
|
23
|
+
* domain, a path no instance has): each must be Porthole's 404 there, never the app's sign-in
|
|
24
|
+
* (P070). Absent: the case is skipped.
|
|
25
|
+
*/
|
|
26
|
+
unknown?(): readonly string[] | Promise<readonly string[]>;
|
|
21
27
|
/** Whether `page` shows the app's own sign-in. */
|
|
22
28
|
atSignIn(page: Page): Promise<boolean>;
|
|
23
29
|
/** On the app's sign-in page: signs in, as a person does (and the app goes back). */
|
|
@@ -31,7 +31,7 @@ export function entryTests(host, options = {}) {
|
|
|
31
31
|
};
|
|
32
32
|
define("served.entry.direct", "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie", async ({ page }) => {
|
|
33
33
|
await host.signIn(page);
|
|
34
|
-
const url = host.url("entry-direct");
|
|
34
|
+
const url = await host.url("entry-direct");
|
|
35
35
|
await page.goto(url);
|
|
36
36
|
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
37
37
|
expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
|
|
@@ -46,7 +46,7 @@ export function entryTests(host, options = {}) {
|
|
|
46
46
|
expect(entries, "a reload needs no entry").toEqual([]);
|
|
47
47
|
});
|
|
48
48
|
define("served.entry.signed-out", "signed out, a clean address goes to the app's own sign-in, then back to the content", async ({ page }) => {
|
|
49
|
-
const url = host.url("entry-signed-out");
|
|
49
|
+
const url = await host.url("entry-signed-out");
|
|
50
50
|
await page.goto(url);
|
|
51
51
|
expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
|
|
52
52
|
expect(await shows(page)).toBe(false);
|
|
@@ -56,15 +56,30 @@ export function entryTests(host, options = {}) {
|
|
|
56
56
|
});
|
|
57
57
|
define("served.entry.denied", "a viewer without access gets the app's refusal, never the content", async ({ page }) => {
|
|
58
58
|
await host.signIn(page);
|
|
59
|
-
const url = host.denied("entry-denied");
|
|
59
|
+
const url = await host.denied("entry-denied");
|
|
60
60
|
const answer = await page.goto(url);
|
|
61
61
|
expect(answer?.status()).toBe(403);
|
|
62
62
|
expect(await shows(page)).toBe(false);
|
|
63
63
|
expect(new URL(page.url()).origin, "the refusal is the app's").not.toBe(new URL(url).origin);
|
|
64
64
|
});
|
|
65
|
+
define("served.entry.unknown", "an address that names nothing is a 404 on the content domain: never a trip to the app's sign-in", async ({ page }, info) => {
|
|
66
|
+
info.skip(host.unknown === undefined, "the host gave no addresses that name nothing (unknown)");
|
|
67
|
+
const request = page.context().request;
|
|
68
|
+
for (const url of (await host.unknown?.()) ?? []) {
|
|
69
|
+
const answer = await request.get(url, {
|
|
70
|
+
maxRedirects: 0,
|
|
71
|
+
headers: { "sec-fetch-dest": "document" },
|
|
72
|
+
});
|
|
73
|
+
expect(answer.status(), url).toBe(404);
|
|
74
|
+
expect(answer.headers().location, url).toBeUndefined();
|
|
75
|
+
const opened = await page.goto(url);
|
|
76
|
+
expect(opened?.status(), url).toBe(404);
|
|
77
|
+
expect(new URL(page.url()).origin, url).toBe(new URL(url).origin);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
65
80
|
define("served.entry.code-once", "an entry code works once: replayed, it opens nothing", async ({ page }) => {
|
|
66
81
|
await host.signIn(page);
|
|
67
|
-
const code = await codeFor(page, host.url("entry-once"));
|
|
82
|
+
const code = await codeFor(page, await host.url("entry-once"));
|
|
68
83
|
const request = page.context().request;
|
|
69
84
|
const top = { "sec-fetch-dest": "document" };
|
|
70
85
|
const first = await request.get(code, { maxRedirects: 0, headers: top });
|
|
@@ -75,8 +90,8 @@ export function entryTests(host, options = {}) {
|
|
|
75
90
|
});
|
|
76
91
|
define("served.entry.code-elsewhere", "an entry code taken to another instance's origin opens nothing there", async ({ page }) => {
|
|
77
92
|
await host.signIn(page);
|
|
78
|
-
const code = new URL(await codeFor(page, host.url("entry-mine")));
|
|
79
|
-
const other = new URL(host.url("entry-theirs"));
|
|
93
|
+
const code = new URL(await codeFor(page, await host.url("entry-mine")));
|
|
94
|
+
const other = new URL(await host.url("entry-theirs"));
|
|
80
95
|
const request = page.context().request;
|
|
81
96
|
const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
|
|
82
97
|
maxRedirects: 0,
|
|
@@ -87,7 +102,7 @@ export function entryTests(host, options = {}) {
|
|
|
87
102
|
});
|
|
88
103
|
define("served.entry.framed", "a clean address in a frame shows the content where the browser keeps the cookies, else asks for a tab: never the app's sign-in", async ({ page, browserName }, info) => {
|
|
89
104
|
await host.signIn(page);
|
|
90
|
-
const shown = await host.frame(page, host.url("entry-framed"));
|
|
105
|
+
const shown = await host.frame(page, await host.url("entry-framed"));
|
|
91
106
|
const outcome = await shown.outcome();
|
|
92
107
|
const frame = await shown.frame();
|
|
93
108
|
expect(await host.atSignIn(page)).toBe(false);
|
|
@@ -110,7 +125,7 @@ export function entryTests(host, options = {}) {
|
|
|
110
125
|
await host.signIn(page);
|
|
111
126
|
// The app's page stays; the browser forgets every sign-in.
|
|
112
127
|
await page.context().clearCookies();
|
|
113
|
-
const shown = await host.frame(page, host.url("entry-framed-out"));
|
|
128
|
+
const shown = await host.frame(page, await host.url("entry-framed-out"));
|
|
114
129
|
expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
|
|
115
130
|
const frame = await shown.frame();
|
|
116
131
|
await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
|
|
@@ -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
|
|
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
|
|
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?"":`
|
|
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 = {
|
|
@@ -50,14 +50,19 @@ export interface HostConformanceOptions {
|
|
|
50
50
|
* signed in and out, and in a frame.
|
|
51
51
|
*/
|
|
52
52
|
readonly entry?: {
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`); may publish it
|
|
55
|
+
* on demand.
|
|
56
|
+
*/
|
|
57
|
+
readonly url: (scope: string) => string | Promise<string>;
|
|
55
58
|
/** A clean address the signed-in viewer may not open. */
|
|
56
|
-
readonly denied: (scope: string) => string
|
|
59
|
+
readonly denied: (scope: string) => string | Promise<string>;
|
|
57
60
|
/** Whether `page` shows the app's own sign-in. */
|
|
58
61
|
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
59
62
|
/** On the app's sign-in page: signs in, as a person does. */
|
|
60
63
|
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
64
|
+
/** Addresses on the content domain that name nothing: each a 404 there (P070). */
|
|
65
|
+
readonly unknown?: () => readonly string[] | Promise<readonly string[]>;
|
|
61
66
|
};
|
|
62
67
|
/** Case ids to skip, each with its reason (shown in the report). */
|
|
63
68
|
readonly skip?: Readonly<Record<string, string>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { runInThisContext } from "node:vm";
|
|
3
4
|
import { expect } from "@playwright/test";
|
|
4
5
|
import { entryTests } from "./entry.js";
|
|
5
6
|
import { leakTests } from "./probes.js";
|
|
@@ -224,9 +225,10 @@ export function hostConformance(options) {
|
|
|
224
225
|
denied: given.denied,
|
|
225
226
|
atSignIn: given.atSignIn,
|
|
226
227
|
completeSignIn: given.completeSignIn,
|
|
228
|
+
...(given.unknown === undefined ? {} : { unknown: given.unknown }),
|
|
227
229
|
async frame(page, url) {
|
|
228
230
|
await ready(page);
|
|
229
|
-
const instance = served.artifact("entry", "1");
|
|
231
|
+
const instance = await served.artifact("entry", "1");
|
|
230
232
|
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
231
233
|
location: { url, origin: new URL(url).origin },
|
|
232
234
|
});
|
|
@@ -310,32 +312,65 @@ export function defineHostConformance(test, options) {
|
|
|
310
312
|
if (options.instances.proxy === undefined) {
|
|
311
313
|
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
312
314
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
typed(each.title, async ({ page, browserName }, info) => {
|
|
325
|
-
await hosts.begin(page);
|
|
326
|
-
await each.run({ page, browserName }, info);
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
const entry = hosts.entry;
|
|
331
|
-
if (entry !== null) {
|
|
332
|
-
typed.describe(`Porthole redirect entry (${options.name})`, () => {
|
|
333
|
-
for (const each of entryTests(entry, { skip })) {
|
|
334
|
-
typed(each.title, async ({ page, browserName }, info) => {
|
|
315
|
+
// Registered as the app's own spec file's (P070): Playwright locates a test where `test()` is
|
|
316
|
+
// called, so `playwright test <spec>` and `<spec>:<line>` select these.
|
|
317
|
+
const at = callerOf(defineHostConformance);
|
|
318
|
+
const describeOf = Reflect.get(test, "describe");
|
|
319
|
+
const describe = calledFrom(describeOf, at);
|
|
320
|
+
const register = calledFrom(typed, at);
|
|
321
|
+
const suite = (title, tests) => {
|
|
322
|
+
describe(title, () => {
|
|
323
|
+
for (const each of tests) {
|
|
324
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
325
|
+
register(each.title, async ({ page, browserName }, info) => {
|
|
335
326
|
await hosts.begin(page);
|
|
336
327
|
await each.run({ page, browserName }, info);
|
|
337
328
|
});
|
|
338
329
|
}
|
|
339
330
|
});
|
|
331
|
+
};
|
|
332
|
+
suite(`Porthole leak suite (${options.name})`, leakTests(hosts.leak, { skip }));
|
|
333
|
+
suite(`Porthole served modes (${options.name})`, servedTests(hosts.served, { skip }));
|
|
334
|
+
if (hosts.entry !== null) {
|
|
335
|
+
suite(`Porthole redirect entry (${options.name})`, entryTests(hosts.entry, { skip }));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Where a function was called from: the caller's file and line, or null. */
|
|
339
|
+
function callerOf(callee) {
|
|
340
|
+
const holder = {};
|
|
341
|
+
Error.captureStackTrace(holder, callee);
|
|
342
|
+
// The first frame is the caller's: " at file:///…/app.spec.ts:16:1" or " at fn (/…/app.spec.ts:16:1)".
|
|
343
|
+
const frame = (holder.stack ?? "")
|
|
344
|
+
.split("\n")
|
|
345
|
+
.map((line) => line.trim())
|
|
346
|
+
.find((line) => line.startsWith("at "));
|
|
347
|
+
const where = (frame ?? "").slice(3).replace(/^.*\((.*)\)$/u, "$1");
|
|
348
|
+
const found = /^(.+):(\d+):\d+$/u.exec(where);
|
|
349
|
+
if (found?.[1] === undefined || found[2] === undefined) {
|
|
350
|
+
return null;
|
|
340
351
|
}
|
|
352
|
+
try {
|
|
353
|
+
return {
|
|
354
|
+
file: found[1].startsWith("file://") ? fileURLToPath(found[1]) : found[1],
|
|
355
|
+
line: Number(found[2]),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `register` (Playwright's `test` or `test.describe`), called from a function compiled as the
|
|
364
|
+
* caller's file and line (`vm`'s `filename` and `lineOffset`), so the tests it makes are located
|
|
365
|
+
* there. Without a caller, `register` itself.
|
|
366
|
+
*/
|
|
367
|
+
function calledFrom(register, at) {
|
|
368
|
+
if (at === null) {
|
|
369
|
+
return (title, body) => register(title, body);
|
|
370
|
+
}
|
|
371
|
+
const call = runInThisContext("(register, title, body) => register(title, body)", {
|
|
372
|
+
filename: at.file,
|
|
373
|
+
lineOffset: Math.max(0, at.line - 1),
|
|
374
|
+
});
|
|
375
|
+
return (title, body) => call(register, title, body);
|
|
341
376
|
}
|
|
@@ -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
|
},
|
|
@@ -53,20 +53,20 @@ export interface ServedConformanceHost {
|
|
|
53
53
|
/** How many requests reached `canary(name)`. */
|
|
54
54
|
canaryHits(name: string): Promise<number>;
|
|
55
55
|
/** A served shell (domain isolation) for `scope`. */
|
|
56
|
-
shell(scope: string): InstanceRef
|
|
56
|
+
shell(scope: string): InstanceRef | Promise<InstanceRef>;
|
|
57
57
|
/**
|
|
58
58
|
* A url-mode app for `scope`: its page loads a script from its own origin that sets
|
|
59
59
|
* `document.documentElement.dataset.app = "ready"`.
|
|
60
60
|
*/
|
|
61
|
-
app(scope: string): InstanceRef
|
|
61
|
+
app(scope: string): InstanceRef | Promise<InstanceRef>;
|
|
62
62
|
/**
|
|
63
63
|
* A stored artifact page (domain isolation) for `scope`: version `"1"` and `"2"` of one page,
|
|
64
64
|
* each with `<main id="artifact">version N</main>` and a script that records
|
|
65
65
|
* `document.documentElement.dataset.rtc = typeof RTCPeerConnection`.
|
|
66
66
|
*/
|
|
67
|
-
artifact(scope: string, version: "1" | "2"): InstanceRef
|
|
67
|
+
artifact(scope: string, version: "1" | "2"): InstanceRef | Promise<InstanceRef>;
|
|
68
68
|
/** The MCP Apps sandbox proxy (domain isolation) for `scope`, opening the view's `egress`. */
|
|
69
|
-
proxy(scope: string, egress?: EgressConfig): InstanceRef
|
|
69
|
+
proxy(scope: string, egress?: EgressConfig): InstanceRef | Promise<InstanceRef>;
|
|
70
70
|
}
|
|
71
71
|
export interface ServedSuiteOptions {
|
|
72
72
|
/** Case ids to skip, each with the reason (shown in the report). */
|
|
@@ -112,7 +112,7 @@ export function servedTests(host, options = {}) {
|
|
|
112
112
|
["url", (scope) => host.app(scope)],
|
|
113
113
|
];
|
|
114
114
|
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
115
|
-
const showMode = (page, mode, instance) => host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
115
|
+
const showMode = async (page, mode, instance) => host.show(page, await instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
116
116
|
for (const [mode, instance] of modes) {
|
|
117
117
|
define(`served.${mode}.origin`, "content runs on its own origin, never the host's", async ({ page }) => {
|
|
118
118
|
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
@@ -190,7 +190,9 @@ export function servedTests(host, options = {}) {
|
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
192
|
define("served.domain.address", "a served frame's address is its own, carrying at most Porthole's ticket: never the app's", async ({ page }) => {
|
|
193
|
-
const display = await host.show(page, host.shell("address"), {
|
|
193
|
+
const display = await host.show(page, await host.shell("address"), {
|
|
194
|
+
content: SHELL_CONTENT,
|
|
195
|
+
});
|
|
194
196
|
const frame = await display.frame();
|
|
195
197
|
const seen = await frame.evaluate(() => ({
|
|
196
198
|
origin: location.origin,
|
|
@@ -203,19 +205,25 @@ export function servedTests(host, options = {}) {
|
|
|
203
205
|
expect(seen.origin).not.toBe(new URL(page.url()).origin);
|
|
204
206
|
});
|
|
205
207
|
define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
|
|
206
|
-
const first = await host.show(page, host.shell("scope-a"), {
|
|
208
|
+
const first = await host.show(page, await host.shell("scope-a"), {
|
|
209
|
+
content: SHELL_CONTENT,
|
|
210
|
+
});
|
|
207
211
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
208
212
|
await first.destroy();
|
|
209
|
-
const again = await host.show(page, host.shell("scope-a"), {
|
|
213
|
+
const again = await host.show(page, await host.shell("scope-a"), {
|
|
214
|
+
content: SHELL_CONTENT,
|
|
215
|
+
});
|
|
210
216
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
211
217
|
expect(again.origin).toBe(first.origin);
|
|
212
|
-
const other = await host.show(page, host.shell("scope-b"), {
|
|
218
|
+
const other = await host.show(page, await host.shell("scope-b"), {
|
|
219
|
+
content: SHELL_CONTENT,
|
|
220
|
+
});
|
|
213
221
|
expect(other.origin).not.toBe(first.origin);
|
|
214
222
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
215
223
|
});
|
|
216
224
|
define("served.artifact.versions", "each version of a page shows as stored, on its scope's origin, hardened before it runs", async ({ page }) => {
|
|
217
|
-
const one = await host.show(page, host.artifact("artifact-scope", "1"));
|
|
218
|
-
const two = await host.show(page, host.artifact("artifact-scope", "2"));
|
|
225
|
+
const one = await host.show(page, await host.artifact("artifact-scope", "1"));
|
|
226
|
+
const two = await host.show(page, await host.artifact("artifact-scope", "2"));
|
|
219
227
|
expect(two.origin).toBe(one.origin);
|
|
220
228
|
for (const [display, version] of [
|
|
221
229
|
[one, "1"],
|
|
@@ -227,7 +235,7 @@ export function servedTests(host, options = {}) {
|
|
|
227
235
|
}
|
|
228
236
|
});
|
|
229
237
|
define("served.domain.ticket-once", "a ticket opens its content once; without it there is nothing", async ({ page, browserName }) => {
|
|
230
|
-
const location = await host.locate(host.shell("ticket-once"));
|
|
238
|
+
const location = await host.locate(await host.shell("ticket-once"));
|
|
231
239
|
const first = await page.goto(location.url);
|
|
232
240
|
expect(first?.status()).toBe(200);
|
|
233
241
|
// A fresh browser state: no cookie, only the used ticket.
|
|
@@ -243,7 +251,7 @@ export function servedTests(host, options = {}) {
|
|
|
243
251
|
}
|
|
244
252
|
});
|
|
245
253
|
define("served.domain.frame-ancestors", "only the host's origins may frame content", async ({ page }) => {
|
|
246
|
-
const location = await host.locate(host.shell("foreign"));
|
|
254
|
+
const location = await host.locate(await host.shell("foreign"));
|
|
247
255
|
await host.frameFromForeignOrigin(page, location.url);
|
|
248
256
|
await page.waitForTimeout(SETTLE_MS);
|
|
249
257
|
for (const frame of page.frames().slice(1)) {
|
|
@@ -264,7 +272,9 @@ export function servedTests(host, options = {}) {
|
|
|
264
272
|
}
|
|
265
273
|
});
|
|
266
274
|
define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
|
|
267
|
-
const display = await host.show(page, host.shell("egress"), {
|
|
275
|
+
const display = await host.show(page, await host.shell("egress"), {
|
|
276
|
+
content: SHELL_CONTENT,
|
|
277
|
+
});
|
|
268
278
|
const frame = await display.frame();
|
|
269
279
|
const kinds = ["fetch", "img", "media", "script", "style", "frame"];
|
|
270
280
|
const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
|
|
@@ -343,7 +353,7 @@ export function servedTests(host, options = {}) {
|
|
|
343
353
|
.poll(async () => methodsOf(await display.received()))
|
|
344
354
|
.toContain(MCP_APPS_METHODS.sandboxProxyReady);
|
|
345
355
|
define("served.proxy.view", "the sandbox proxy becomes the view, hardened, talking to its host directly", async ({ page }) => {
|
|
346
|
-
const display = await host.show(page, host.proxy("proxy-view"));
|
|
356
|
+
const display = await host.show(page, await host.proxy("proxy-view"));
|
|
347
357
|
await proxyReady(display);
|
|
348
358
|
const canary = await host.canary("proxy-undeclared");
|
|
349
359
|
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
@@ -357,7 +367,7 @@ export function servedTests(host, options = {}) {
|
|
|
357
367
|
expect(await host.canaryHits("proxy-undeclared")).toBe(0);
|
|
358
368
|
});
|
|
359
369
|
define("served.proxy.parent-only", "the sandbox proxy takes a view only from its host", async ({ page }) => {
|
|
360
|
-
const display = await host.show(page, host.proxy("proxy-parent"));
|
|
370
|
+
const display = await host.show(page, await host.proxy("proxy-parent"));
|
|
361
371
|
await proxyReady(display);
|
|
362
372
|
const frame = await display.frame();
|
|
363
373
|
await frame.evaluate((message) => window.postMessage(message, "*"), resourceReady('<p id="view">Not from the host.</p>'));
|
|
@@ -366,7 +376,7 @@ export function servedTests(host, options = {}) {
|
|
|
366
376
|
});
|
|
367
377
|
define("served.proxy.egress", "the sandbox proxy opens what the view declares, and nothing else", async ({ page }) => {
|
|
368
378
|
const canary = await host.canary("proxy-declared");
|
|
369
|
-
const display = await host.show(page, host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
|
|
379
|
+
const display = await host.show(page, await host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
|
|
370
380
|
await proxyReady(display);
|
|
371
381
|
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
372
382
|
const frame = await display.frame();
|
|
@@ -374,7 +384,7 @@ export function servedTests(host, options = {}) {
|
|
|
374
384
|
expect(await host.canaryHits("proxy-declared")).toBe(1);
|
|
375
385
|
});
|
|
376
386
|
define("served.url.ticket", "an app shows nothing to a browser without its ticket's cookie", async ({ page }) => {
|
|
377
|
-
const location = await host.locate(host.app("url-ticket"));
|
|
387
|
+
const location = await host.locate(await host.app("url-ticket"));
|
|
378
388
|
// A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
|
|
379
389
|
// (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
|
|
380
390
|
const fresh = await page.context().browser().newContext();
|
|
@@ -390,7 +400,7 @@ export function servedTests(host, options = {}) {
|
|
|
390
400
|
}
|
|
391
401
|
});
|
|
392
402
|
define("served.url.tab", "an app opened on a tab of its own runs, its requests riding on its cookie", async ({ page }) => {
|
|
393
|
-
const location = await host.locate(host.app("url-tab"));
|
|
403
|
+
const location = await host.locate(await host.app("url-tab"));
|
|
394
404
|
await page.goto(location.url);
|
|
395
405
|
await expect(page.locator("html")).toHaveAttribute("data-app", "ready");
|
|
396
406
|
});
|
|
@@ -398,7 +408,7 @@ export function servedTests(host, options = {}) {
|
|
|
398
408
|
// Measured (P025): WebKit keeps no cookie in a cross-site frame, partitioned or not, so a
|
|
399
409
|
// framed app's subresources are refused there; such hosts open the app on a tab.
|
|
400
410
|
typed.fail(browserName === "webkit", "WebKit keeps no cookie in a cross-site frame");
|
|
401
|
-
const display = await host.show(page, host.app("url-framed"));
|
|
411
|
+
const display = await host.show(page, await host.app("url-framed"));
|
|
402
412
|
const frame = await display.frame();
|
|
403
413
|
await expect
|
|
404
414
|
.poll(() => frame.evaluate(() => document.documentElement.dataset.app ?? null), {
|
package/dist/guide/guide.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
: `
|
|
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)" : ""}`;
|
package/dist/guide/limits.js
CHANGED
|
@@ -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/dist/node/index.d.ts
CHANGED
|
@@ -27,9 +27,17 @@ export interface NodeHost {
|
|
|
27
27
|
readonly request: RequestListener;
|
|
28
28
|
readonly upgrade: UpgradeListener;
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
|
|
32
|
+
* (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toRequest(request: IncomingMessage, scheme: "https" | "http", signal?: AbortSignal): Request;
|
|
35
|
+
/**
|
|
36
|
+
* Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
|
|
37
|
+
* socket to drain before reading on (a slow client never makes it buffer the body), cancels the
|
|
38
|
+
* body when the client goes away (an event stream ends with the page that asked for it), and on
|
|
39
|
+
* an error mid-body destroys the response rather than end it looking complete.
|
|
40
|
+
*/
|
|
33
41
|
export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
|
|
34
42
|
export declare function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): NodeHost;
|
|
35
43
|
/**
|