@botiverse/porthole 0.1.2 → 0.1.4

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.
Files changed (84) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +10 -3
  8. package/dist/compile/policy.js +16 -5
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +11 -0
  12. package/dist/config/validate.js +12 -0
  13. package/dist/conformance/clipboard.d.ts +2 -0
  14. package/dist/conformance/clipboard.js +167 -0
  15. package/dist/conformance/kit.d.ts +1 -1
  16. package/dist/conformance/probes.js +2 -0
  17. package/dist/conformance/served.d.ts +8 -1
  18. package/dist/conformance/served.js +12 -8
  19. package/dist/guide/guide.js +11 -5
  20. package/dist/host/clipboard.d.ts +11 -0
  21. package/dist/host/clipboard.js +43 -0
  22. package/dist/host/create-host.js +165 -7
  23. package/dist/host/types.d.ts +68 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -3
  26. package/dist/mcp/adapter.js +10 -1
  27. package/dist/presets/presets.d.ts +3 -1
  28. package/dist/presets/presets.js +1 -0
  29. package/dist/protocol/mcp-apps.d.ts +5 -0
  30. package/dist/protocol/mcp-apps.js +5 -0
  31. package/dist/protocol/shell-adapter.js +6 -2
  32. package/dist/react/dialog.js +2 -0
  33. package/dist/react/frame.d.ts +50 -2
  34. package/dist/react/frame.js +153 -11
  35. package/dist/react/index.d.ts +2 -1
  36. package/dist/react/index.js +2 -1
  37. package/dist/react/toast.d.ts +33 -0
  38. package/dist/react/toast.js +42 -0
  39. package/dist/server/naming.d.ts +6 -0
  40. package/dist/server/naming.js +38 -0
  41. package/dist/server/pages.js +2 -0
  42. package/dist/server/server.js +49 -17
  43. package/dist/server/types.d.ts +6 -3
  44. package/dist/shell/annotate.d.ts +20 -0
  45. package/dist/shell/annotate.js +700 -0
  46. package/dist/shell/document.d.ts +6 -0
  47. package/dist/shell/document.js +12 -3
  48. package/dist/shell/plan.d.ts +2 -0
  49. package/dist/shell/plan.js +2 -1
  50. package/dist/shell/runtime.d.ts +4 -1
  51. package/dist/shell/runtime.js +71 -3
  52. package/package.json +1 -1
  53. package/src/annotate/types.ts +126 -0
  54. package/src/annotate/validate.ts +186 -0
  55. package/src/bridge/bridge.ts +13 -0
  56. package/src/compile/policy.ts +17 -5
  57. package/src/config/capabilities.ts +33 -3
  58. package/src/config/schema.ts +12 -0
  59. package/src/config/validate.ts +15 -0
  60. package/src/conformance/clipboard.ts +184 -0
  61. package/src/conformance/kit.ts +1 -1
  62. package/src/conformance/probes.ts +2 -0
  63. package/src/conformance/served.ts +24 -9
  64. package/src/guide/guide.ts +22 -5
  65. package/src/host/clipboard.ts +41 -0
  66. package/src/host/create-host.ts +181 -7
  67. package/src/host/types.ts +64 -2
  68. package/src/index.ts +24 -0
  69. package/src/mcp/adapter.ts +10 -1
  70. package/src/presets/presets.ts +4 -0
  71. package/src/protocol/mcp-apps.ts +6 -0
  72. package/src/protocol/shell-adapter.ts +11 -2
  73. package/src/react/dialog.tsx +2 -0
  74. package/src/react/frame.tsx +231 -13
  75. package/src/react/index.ts +9 -1
  76. package/src/react/toast.tsx +96 -0
  77. package/src/server/naming.ts +41 -0
  78. package/src/server/pages.ts +2 -0
  79. package/src/server/server.ts +61 -20
  80. package/src/server/types.ts +6 -3
  81. package/src/shell/annotate.ts +700 -0
  82. package/src/shell/document.ts +21 -3
  83. package/src/shell/plan.ts +4 -1
  84. package/src/shell/runtime.ts +71 -3
@@ -0,0 +1,167 @@
1
+ import { expect } from "@playwright/test";
2
+ import { inline } from "./kit.js";
3
+ import { settle } from "./oracles.js";
4
+ /**
5
+ * The clipboard (item 13, P051): content can't write it, whatever it tries in a person's click,
6
+ * and what a person copies themselves is what they selected. `copyText` is the way copy buttons
7
+ * work instead (item 12, P052). The oracle is the person's own clipboard: known text put on it
8
+ * from the host's page, then pasted back into a text area there.
9
+ */
10
+ const SEED = "porthole-seed";
11
+ /** A text area of the oracle's own on the host's page. */
12
+ async function area(page, id) {
13
+ await page.evaluate((each) => {
14
+ if (document.getElementById(each) === null) {
15
+ const element = document.createElement("textarea");
16
+ element.id = each;
17
+ element.style.cssText = "display:block;width:200px;height:40px";
18
+ document.body.prepend(element);
19
+ }
20
+ }, id);
21
+ }
22
+ /** Puts `text` on the clipboard the way a person does: selects it on the host's page, copies. */
23
+ async function seed(page, text) {
24
+ await area(page, "porthole-oracle-seed");
25
+ await page.evaluate((each) => {
26
+ document.getElementById("porthole-oracle-seed").value = each;
27
+ }, text);
28
+ await page.locator("#porthole-oracle-seed").click();
29
+ await page.keyboard.press("ControlOrMeta+A");
30
+ await page.keyboard.press("ControlOrMeta+C");
31
+ }
32
+ /** What is on the clipboard: pasted into a text area on the host's page. */
33
+ async function pasted(page) {
34
+ await area(page, "porthole-oracle-paste");
35
+ await page.evaluate(() => {
36
+ document.getElementById("porthole-oracle-paste").value = "";
37
+ });
38
+ await page.locator("#porthole-oracle-paste").click();
39
+ await page.keyboard.press("ControlOrMeta+V");
40
+ await settle(200);
41
+ return page.evaluate(() => document.getElementById("porthole-oracle-paste").value);
42
+ }
43
+ /** Clicks `selector` in the display (a real click: the user activation a person gives). */
44
+ async function click(display, selector) {
45
+ await (await display.frame()).locator(selector).click();
46
+ }
47
+ async function bodyData(display, name) {
48
+ const frame = await display.frame();
49
+ await expect
50
+ .poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout: 5_000 })
51
+ .not.toBeNull();
52
+ return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
53
+ }
54
+ const settled = (name) => `(value) => (document.body.dataset.${name} = String(value)), (error) => (document.body.dataset.${name} = "refused: " + (error && error.name))`;
55
+ export const CLIPBOARD_PROBES = [
56
+ {
57
+ id: "13.clipboard-api",
58
+ item: 13,
59
+ title: "content can't write (or read) the clipboard through the async Clipboard API, even in a click",
60
+ async run(context) {
61
+ const display = await context.launch({
62
+ config: inline(),
63
+ content: `<button id="go">copy</button>
64
+ <script>
65
+ document.getElementById("go").addEventListener("click", () => {
66
+ navigator.clipboard.writeText("porthole-pwned-text").then(${settled("text")});
67
+ if (typeof ClipboardItem === "function") {
68
+ navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob(["porthole-pwned-item"], { type: "text/plain" }) })]).then(${settled("item")});
69
+ } else {
70
+ document.body.dataset.item = "refused: no ClipboardItem";
71
+ }
72
+ navigator.clipboard.readText().then(${settled("read")});
73
+ });
74
+ </script>`,
75
+ });
76
+ await seed(context.page, SEED);
77
+ await click(display, "#go");
78
+ expect(await bodyData(display, "text")).toMatch(/^refused/u);
79
+ expect(await bodyData(display, "item")).toMatch(/^refused/u);
80
+ expect(await bodyData(display, "read")).toMatch(/^refused/u);
81
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
82
+ },
83
+ },
84
+ {
85
+ id: "13.clipboard-command",
86
+ item: 13,
87
+ title: "content can't copy or cut its own text onto the clipboard with execCommand, even in a click",
88
+ async run(context) {
89
+ const display = await context.launch({
90
+ config: inline(),
91
+ content: `<button id="go">copy</button>
92
+ <script>
93
+ document.getElementById("go").addEventListener("click", () => {
94
+ const area = document.createElement("textarea");
95
+ area.value = "porthole-pwned-command";
96
+ area.style.cssText = "position:absolute;left:-9999px";
97
+ document.body.append(area);
98
+ area.select();
99
+ let copied = "threw";
100
+ let cut = "threw";
101
+ try { copied = document.execCommand("copy"); } catch (_) {}
102
+ area.select();
103
+ try { cut = document.execCommand("cut"); } catch (_) {}
104
+ document.body.dataset.copied = String(copied) + "," + String(cut);
105
+ });
106
+ </script>`,
107
+ });
108
+ await seed(context.page, SEED);
109
+ await click(display, "#go");
110
+ expect(await bodyData(display, "copied")).not.toContain("true");
111
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
112
+ },
113
+ },
114
+ {
115
+ id: "13.clipboard-event",
116
+ item: 13,
117
+ title: "what a person copies is what they selected: no copy listener of the content's can swap it",
118
+ async run(context) {
119
+ const display = await context.launch({
120
+ config: inline(),
121
+ content: `<p id="safe">porthole-selected-text</p>
122
+ <script>
123
+ const swap = (event) => {
124
+ event.clipboardData.setData("text/plain", "porthole-pwned-event");
125
+ event.preventDefault();
126
+ document.body.dataset.ran = "yes";
127
+ };
128
+ document.addEventListener("copy", swap);
129
+ document.addEventListener("copy", swap, true);
130
+ window.addEventListener("copy", swap);
131
+ document.getElementById("safe").oncopy = swap;
132
+ </script>`,
133
+ });
134
+ await seed(context.page, SEED);
135
+ const frame = await display.frame();
136
+ await frame.locator("#safe").click({ clickCount: 3 });
137
+ await context.page.keyboard.press("ControlOrMeta+C");
138
+ await settle(200);
139
+ // The positive control: the person's own copy works, and copies what they selected.
140
+ expect((await pasted(context.page)).trim()).toBe("porthole-selected-text");
141
+ expect(await frame.locator("body").getAttribute("data-ran")).toBeNull();
142
+ },
143
+ },
144
+ {
145
+ id: "12.copy-text",
146
+ item: 12,
147
+ title: "copyText reaches the host's handler from a click, with the whole text, never without one",
148
+ async run(context) {
149
+ const display = await context.launch({
150
+ config: inline({ capabilities: { copyText: {} } }),
151
+ content: `<button id="copy">Copy the command</button>
152
+ <script>
153
+ document.getElementById("copy").addEventListener("click", () => {
154
+ copyText("npm test -- --run").then(${settled("result")});
155
+ });
156
+ copyText("copied on load").then(${settled("onload")});
157
+ </script>`,
158
+ });
159
+ expect(await bodyData(display, "onload")).toMatch(/^refused/u);
160
+ await click(display, "#copy");
161
+ expect(await bodyData(display, "result")).toBe("true");
162
+ expect(await display.calls()).toEqual([
163
+ { capability: "copyText", args: { text: "npm test -- --run" } },
164
+ ]);
165
+ },
166
+ },
167
+ ];
@@ -26,7 +26,7 @@ export interface Probe {
26
26
  readonly id: string;
27
27
  /**
28
28
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
29
- * behavior (streaming, sizing, theming), 12 for host-function gates.
29
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
30
30
  */
31
31
  readonly item: number;
32
32
  readonly title: string;
@@ -3,6 +3,7 @@ import { presets } from "../presets/presets.js";
3
3
  import { resolveTheme } from "../theme/variables.js";
4
4
  import { attack, inline, js } from "./kit.js";
5
5
  import { BEHAVIOR_PROBES } from "./behavior.js";
6
+ import { CLIPBOARD_PROBES } from "./clipboard.js";
6
7
  import { readFileSync } from "node:fs";
7
8
  import { createRequire } from "node:module";
8
9
  import { httpOracle, settle, udpOracle, } from "./oracles.js";
@@ -817,6 +818,7 @@ export const PROBES = [
817
818
  },
818
819
  },
819
820
  ...BEHAVIOR_PROBES,
821
+ ...CLIPBOARD_PROBES,
820
822
  ];
821
823
  /** Every probe as a test entry (see `ConformanceTest`). */
822
824
  export function leakTests(host, options = {}) {
@@ -35,8 +35,15 @@ export interface ServedConformanceHost {
35
35
  * Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
36
36
  * the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
37
37
  * may show several, one after another.
38
+ *
39
+ * For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
40
+ * the shell (`createHost`, the React binding) sends it as the display's content, since a shell
41
+ * draws nothing (and reports no height) until it has content, and the cases click inside it. A
42
+ * host whose frame has a height of its own may ignore it.
38
43
  */
39
- show(page: Page, instance: InstanceRef): Promise<ServedDisplay>;
44
+ show(page: Page, instance: InstanceRef, options?: {
45
+ readonly content?: string;
46
+ }): Promise<ServedDisplay>;
40
47
  /** A fresh ticketed location for `instance`, as the host's backend issues it. */
41
48
  locate(instance: InstanceRef): Promise<FrameLocation>;
42
49
  /** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
@@ -14,6 +14,8 @@
14
14
  import { expect } from "@playwright/test";
15
15
  import { MCP_APPS_METHODS } from "../protocol/mcp-apps.js";
16
16
  const SETTLE_MS = 800;
17
+ /** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
18
+ const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
17
19
  /** What the frame can see of its host: everything here must be closed to it. */
18
20
  async function reachIntoHost(frame) {
19
21
  return frame.evaluate(() => {
@@ -109,21 +111,23 @@ export function servedTests(host, options = {}) {
109
111
  ["domain", (scope) => host.shell(scope)],
110
112
  ["url", (scope) => host.app(scope)],
111
113
  ];
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 } : {});
112
116
  for (const [mode, instance] of modes) {
113
117
  define(`served.${mode}.origin`, "content runs on its own origin, never the host's", async ({ page }) => {
114
- const display = await host.show(page, instance(`${mode}-origin`));
118
+ const display = await showMode(page, mode, instance(`${mode}-origin`));
115
119
  const frame = await display.frame();
116
120
  const origin = await frame.evaluate(() => window.origin);
117
121
  expect(origin).toBe(display.origin);
118
122
  expect(origin).not.toBe(new URL(page.url()).origin);
119
123
  });
120
124
  define(`served.${mode}.no-reach`, "content can't reach the host's DOM, storage, cookies or URL", async ({ page }) => {
121
- const display = await host.show(page, instance(`${mode}-reach`));
125
+ const display = await showMode(page, mode, instance(`${mode}-reach`));
122
126
  await plantHostSecrets(page);
123
127
  expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
124
128
  });
125
129
  define(`served.${mode}.no-top-navigation`, "content can't navigate the host's page, even after a click", async ({ page }) => {
126
- const display = await host.show(page, instance(`${mode}-top`));
130
+ const display = await showMode(page, mode, instance(`${mode}-top`));
127
131
  const frame = await display.frame();
128
132
  const before = page.url();
129
133
  const target = await host.canary(`${mode}-top`);
@@ -153,7 +157,7 @@ export function servedTests(host, options = {}) {
153
157
  expect(await host.canaryHits(`${mode}-top`)).toBe(0);
154
158
  });
155
159
  define(`served.${mode}.no-popups`, "content can't open a window, even after a click", async ({ page }) => {
156
- const display = await host.show(page, instance(`${mode}-popup`));
160
+ const display = await showMode(page, mode, instance(`${mode}-popup`));
157
161
  const frame = await display.frame();
158
162
  const target = await host.canary(`${mode}-popup`);
159
163
  const opened = [];
@@ -186,13 +190,13 @@ export function servedTests(host, options = {}) {
186
190
  });
187
191
  }
188
192
  define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
189
- const first = await host.show(page, host.shell("scope-a"));
193
+ const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
190
194
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
191
195
  await first.destroy();
192
- const again = await host.show(page, host.shell("scope-a"));
196
+ const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
193
197
  expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
194
198
  expect(again.origin).toBe(first.origin);
195
- const other = await host.show(page, host.shell("scope-b"));
199
+ const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
196
200
  expect(other.origin).not.toBe(first.origin);
197
201
  expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
198
202
  });
@@ -247,7 +251,7 @@ export function servedTests(host, options = {}) {
247
251
  }
248
252
  });
249
253
  define("served.domain.egress", "the CSP in the document's headers closes egress", async ({ page }) => {
250
- const display = await host.show(page, host.shell("egress"));
254
+ const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
251
255
  const frame = await display.frame();
252
256
  const fetched = await frame.evaluate(async (url) => {
253
257
  try {
@@ -1,4 +1,4 @@
1
- import { capabilitySpec } from "../config/capabilities.js";
1
+ import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
2
2
  import { BUILT_IN_TOPICS } from "../config/validate.js";
3
3
  import { declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
4
4
  import { shellPlan } from "../shell/plan.js";
@@ -67,7 +67,7 @@ function network(config) {
67
67
  const optsIn = (config) => config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
68
68
  const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
69
69
  /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
70
- function forbidden(config) {
70
+ function forbidden(config, c) {
71
71
  const tokens = sandboxTokens(config);
72
72
  const items = ["position: fixed", "full screen"];
73
73
  if (!tokens.includes("allow-modals")) {
@@ -82,7 +82,7 @@ function forbidden(config) {
82
82
  if (!tokens.includes("allow-downloads")) {
83
83
  items.push("downloads");
84
84
  }
85
- items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
85
+ items.push(c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard", "frames (iframe, object, embed)", "WebRTC");
86
86
  return `Not allowed: ${items.join(", ")}.`;
87
87
  }
88
88
  function widget(c) {
@@ -102,7 +102,7 @@ function widget(c) {
102
102
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
103
103
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
104
104
  const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
105
- return lines(`# ${title}${tool}`, "", what, "", `Where it runs: 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 box's clientWidth, again from a ResizeObserver). ${height} ${optsIn(config) ? `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.` : "It runs in the person's light or dark 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.", "", `${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`, loadsScripts(config)
105
+ return lines(`# ${title}${tool}`, "", what, "", `Where it runs: 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 box's clientWidth, again from a ResizeObserver). ${height} ${optsIn(config) ? `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.` : "It runs in the person's light or dark 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.", "", `${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`, loadsScripts(config)
106
106
  ? "\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."
107
107
  : null, "", optsIn(config)
108
108
  ? `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).`
@@ -189,6 +189,10 @@ const ENTRIES = {
189
189
  callTool: (_c, gates) => `- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${gateWords(gates, "the call")}`,
190
190
  readData: (_c, gates) => `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
191
191
  setModelContext: (_c, gates) => `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
192
+ copyText: (c, gates) => c.global("copyText")
193
+ ? `- copyText(text): copies text to the person's clipboard (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters), and the app shows them what was copied. Use it for every copy button: nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")} It returns a promise: true if it was copied.`
194
+ : `- Copying: the request porthole/copy-text with { text } (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters) copies text to the person's clipboard, and the app shows them what was copied; nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")}`,
195
+ annotate: (c) => `- Comments: the person may point at parts of the ${c.noun} to comment on them. Give headings and the parts that matter stable ids (id="revenue"), so comments stay on them when the ${c.noun} is written again.`,
192
196
  reportError: (c, gates) => `- Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${c.config.limits.maxErrorReports} a run). ${gateWords(gates, "it")}`.trim(),
193
197
  resize: () => "",
194
198
  };
@@ -202,7 +206,9 @@ function interactive(c) {
202
206
  .filter((entry) => entry !== "");
203
207
  const state = config.isolation === "opaque"
204
208
  ? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
205
- : `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
209
+ : config.storage?.shared === undefined
210
+ ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
211
+ : `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
206
212
  const none = entries.length === 0
207
213
  ? `- It can only draw: it has no way to send anything to you or the app.`
208
214
  : null;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+ /**
7
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
8
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
9
+ * text either way (and offers its own Copy button when it failed).
10
+ */
11
+ export declare function writeClipboard(text: string, document: Document): Promise<boolean>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+ /**
7
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
8
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
9
+ * text either way (and offers its own Copy button when it failed).
10
+ */
11
+ export async function writeClipboard(text, document) {
12
+ const view = document.defaultView;
13
+ try {
14
+ if (view?.navigator.clipboard !== undefined) {
15
+ await view.navigator.clipboard.writeText(text);
16
+ return true;
17
+ }
18
+ }
19
+ catch {
20
+ // Fall through to the copy command.
21
+ }
22
+ const focused = document.activeElement;
23
+ const area = document.createElement("textarea");
24
+ area.value = text;
25
+ area.setAttribute("readonly", "");
26
+ area.setAttribute("aria-hidden", "true");
27
+ area.style.cssText =
28
+ "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;border:0;padding:0";
29
+ try {
30
+ document.body.append(area);
31
+ area.select();
32
+ return document.execCommand("copy");
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ finally {
38
+ area.remove();
39
+ if (focused !== null && typeof focused.focus === "function") {
40
+ focused.focus({ preventScroll: true });
41
+ }
42
+ }
43
+ }