@botiverse/porthole 0.1.6 → 0.1.7

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.
@@ -11,12 +11,12 @@ import {
11
11
  declaredCapabilities,
12
12
  effectiveGates,
13
13
  egressOrigins,
14
- sandboxTokens,
15
14
  type EgressKind,
16
15
  } from "../compile/policy.ts";
17
16
  import { shellPlan } from "../shell/plan.ts";
18
17
  import { themeDefault } from "../theme/variables.ts";
19
18
  import { GUIDE_EXAMPLES } from "./examples.ts";
19
+ import { manualLimits, sizeWords } from "./limits.ts";
20
20
 
21
21
  /**
22
22
  * The agent manual and the tool description, generated from the config (PRINCIPLE 3, 10): the
@@ -129,30 +129,6 @@ const optsIn = (config: PortholeConfig): boolean =>
129
129
  const loadsScripts = (config: PortholeConfig): boolean =>
130
130
  egressOrigins(config, "script").length > 0;
131
131
 
132
- /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
133
- function forbidden(config: PortholeConfig, c: Context): string {
134
- const tokens = sandboxTokens(config);
135
- const items = ["position: fixed", "full screen"];
136
- if (!tokens.includes("allow-modals")) {
137
- items.push("alert/confirm/prompt");
138
- }
139
- if (!tokens.includes("allow-forms")) {
140
- items.push("forms that submit");
141
- }
142
- if (!tokens.includes("allow-popups")) {
143
- items.push("popups and new windows");
144
- }
145
- if (!tokens.includes("allow-downloads")) {
146
- items.push("downloads");
147
- }
148
- items.push(
149
- c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard",
150
- "frames (iframe, object, embed)",
151
- "WebRTC",
152
- );
153
- return `Not allowed: ${items.join(", ")}.`;
154
- }
155
-
156
132
  function widget(c: Context): string {
157
133
  const { config, noun } = c;
158
134
  const { layout, limits } = config;
@@ -164,9 +140,10 @@ function widget(c: Context): string {
164
140
  : config.mode === "mcp-app"
165
141
  ? "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."
166
142
  : "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.";
167
- const height = c.declared("resize")
168
- ? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
169
- : "Its height is fixed by the app: keep it short, or let it scroll inside.";
143
+ const stated = manualLimits(config, network(config));
144
+ const said = (id: string) => stated.find((limit) => limit.id === id)?.text ?? "";
145
+ const fills = layout.kind === "fill";
146
+ const themeless = config.theme.documents === "never";
170
147
  const viewBox = layout.narrowWidthPx + 20;
171
148
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
172
149
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
@@ -180,7 +157,7 @@ function widget(c: Context): string {
180
157
  "",
181
158
  what,
182
159
  "",
183
- `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" : ""}.`,
160
+ `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
184
161
  "",
185
162
  "Sizes that read on a phone:",
186
163
  c.when(
@@ -190,15 +167,22 @@ function widget(c: Context): string {
190
167
  `- 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.`,
191
168
  "- 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, …).",
192
169
  "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
170
+ fills
171
+ ? null
172
+ : "- No position: fixed: the frame grows with its content, so a fixed element covers it.",
193
173
  "",
194
- `${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
174
+ "The sandbox:",
175
+ ...stated.filter((limit) => limit.topic === "sandbox").map((limit) => `- ${limit.text}`),
176
+ `Keep it under ${sizeWords(limits.maxBytes)}.`,
195
177
  loadsScripts(config)
196
178
  ? "\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."
197
179
  : null,
198
180
  "",
199
- optsIn(config)
200
- ? `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).`
201
- : `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).`,
181
+ themeless
182
+ ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
183
+ : optsIn(config)
184
+ ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
185
+ : `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).`,
202
186
  c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`,
203
187
  c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`,
204
188
  c.names("--color-border-") === ""
@@ -225,7 +209,9 @@ function widget(c: Context): string {
225
209
  c.when(
226
210
  `Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`,
227
211
  ),
228
- `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.`,
212
+ themeless
213
+ ? null
214
+ : `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.`,
229
215
  config.mode === "inline"
230
216
  ? "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."
231
217
  : null,
@@ -272,7 +258,7 @@ function chart(c: Context): string {
272
258
  /^--color-chart-seq-\d+$/u.test(name),
273
259
  );
274
260
  const libraries = loadsScripts(config)
275
- ? `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("${categorical[0] ?? "--color-text-primary"}").trim(), redraw on window "${config.theme.event}", and override their palette and white background.`
261
+ ? `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("${categorical[0] ?? "--color-text-primary"}").trim()${config.theme.documents === "never" ? "" : `, redraw on window "${config.theme.event}"`}, and override their palette and white background.`
276
262
  : "No chart library loads here: draw the SVG yourself, from a script, as below.";
277
263
  return lines(
278
264
  `# Charts (a chart is a ${c.noun})`,
@@ -362,8 +348,8 @@ const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Ga
362
348
  : `- 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")}`,
363
349
  annotate: (c) =>
364
350
  `- 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.`,
365
- reportError: (c, gates) =>
366
- `- 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(),
351
+ reportError: (c) =>
352
+ `- ${manualLimits(c.config, "").find((limit) => limit.id === "errors")?.text ?? ""}`,
367
353
  resize: () => "",
368
354
  };
369
355
 
@@ -375,17 +361,18 @@ function interactive(c: Context): string {
375
361
  return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
376
362
  })
377
363
  .filter((entry) => entry !== "");
378
- const state =
379
- config.isolation === "opaque"
380
- ? `- 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.`
381
- : config.storage?.shared === undefined
382
- ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
383
- : `- 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.`;
364
+ const stated = manualLimits(config, "");
365
+ const stored = stated.find((limit) => limit.id === "storage");
366
+ const state = stored === undefined ? null : `- ${stored.text}`;
367
+ // Without reportError the capability list has no errors entry: say that nothing reports them.
368
+ const errors = c.declared("reportError")
369
+ ? null
370
+ : `- ${stated.find((limit) => limit.id === "errors")?.text ?? ""}`;
384
371
  const none =
385
372
  entries.length === 0
386
373
  ? `- It can only draw: it has no way to send anything to you or the app.`
387
374
  : null;
388
- return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
375
+ return lines(`# Interactive ${noun}s`, "", ...entries, errors, none, state);
389
376
  }
390
377
 
391
378
  const TOPICS: Readonly<Record<BuiltInTopic, (c: Context) => string>> = {
@@ -459,7 +446,15 @@ export function generateToolDescription(config: PortholeConfig): string {
459
446
  config.guide.guideToolName === undefined
460
447
  ? ""
461
448
  : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
462
- return `${what} 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)" : ""}, in the person's light or dark theme through CSS variables, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(
449
+ const where =
450
+ layout.kind === "fill"
451
+ ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
452
+ : `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)" : ""}`;
453
+ const theme =
454
+ config.theme.documents === "never"
455
+ ? "always in light (the app doesn't theme it)"
456
+ : "in the person's light or dark theme through CSS variables";
457
+ return `${what} ${where}, ${theme}, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(
463
458
  / {2,}/gu,
464
459
  " ",
465
460
  );
@@ -0,0 +1,224 @@
1
+ import { declaredCapabilities, egressOrigins, sandboxTokens } from "../compile/policy.ts";
2
+ import type { CapabilityName } from "../config/capabilities.ts";
3
+ import type { PortholeConfig } from "../config/schema.ts";
4
+ import { shellPlan } from "../shell/plan.ts";
5
+
6
+ /**
7
+ * The sandbox's limits as the manual states them (P064): each a short line, derived from the
8
+ * config, for what silently changes how content behaves. Every limit has an id, and the
9
+ * conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
10
+ * no browser test proves. What differs by engine is stated as the behavior safe on all of them.
11
+ */
12
+
13
+ export const LIMIT_IDS = [
14
+ "layout",
15
+ "theme",
16
+ "silent",
17
+ "network",
18
+ "inline-assets",
19
+ "dialogs",
20
+ "print",
21
+ "downloads",
22
+ "forms",
23
+ "link-schemes",
24
+ "windows",
25
+ "devices",
26
+ "workers",
27
+ "sound",
28
+ "address",
29
+ "shell",
30
+ "storage",
31
+ "errors",
32
+ ] as const;
33
+
34
+ export type LimitId = (typeof LIMIT_IDS)[number];
35
+
36
+ /** Where the manual states a limit: where it runs, the sandbox list, or the interactive topic. */
37
+ export type LimitTopic = "where" | "sandbox" | "interactive";
38
+
39
+ export interface ManualLimit {
40
+ readonly id: LimitId;
41
+ readonly text: string;
42
+ readonly topic: LimitTopic;
43
+ }
44
+
45
+ const TOPICS: { readonly [Id in LimitId]: LimitTopic } = {
46
+ layout: "where",
47
+ theme: "where",
48
+ storage: "interactive",
49
+ errors: "interactive",
50
+ silent: "sandbox",
51
+ network: "sandbox",
52
+ "inline-assets": "sandbox",
53
+ dialogs: "sandbox",
54
+ print: "sandbox",
55
+ downloads: "sandbox",
56
+ forms: "sandbox",
57
+ "link-schemes": "sandbox",
58
+ windows: "sandbox",
59
+ devices: "sandbox",
60
+ workers: "sandbox",
61
+ sound: "sandbox",
62
+ address: "sandbox",
63
+ shell: "sandbox",
64
+ };
65
+
66
+ const noun = (config: PortholeConfig): string =>
67
+ config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget";
68
+
69
+ /** Whether content may load `kind` from somewhere outside (listed origins, or any https site). */
70
+ const opens = (config: PortholeConfig, kind: "img" | "style" | "font" | "media"): boolean =>
71
+ egressOrigins(config, kind).length > 0 ||
72
+ (config.egress.anyHttps ?? []).some((each) => each === kind);
73
+
74
+ /**
75
+ * The limits the manual states for `config`, in the order it states them. `network` is the
76
+ * caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
77
+ * Url mode has no shell and no manual: none.
78
+ */
79
+ export function manualLimits(config: PortholeConfig, network: string): ManualLimit[] {
80
+ if (config.mode === "url") {
81
+ return [];
82
+ }
83
+ const tokens = sandboxTokens(config);
84
+ const declared = new Set<CapabilityName>(declaredCapabilities(config));
85
+ const globals = new Set(shellPlan(config)?.globals ?? []);
86
+ const it = noun(config);
87
+ const out: ManualLimit[] = [];
88
+ const add = (id: LimitId, text: string | null) => {
89
+ if (text !== null) {
90
+ out.push({ id, text, topic: TOPICS[id] });
91
+ }
92
+ };
93
+
94
+ // Where it runs (P065): a card that grows with its content, or the whole viewer.
95
+ add(
96
+ "layout",
97
+ config.layout.kind === "fill"
98
+ ? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
99
+ : declared.has("resize")
100
+ ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
101
+ : "Its height is fixed by the app: keep it short, or let it scroll inside.",
102
+ );
103
+ const documents = config.theme.documents ?? "declared";
104
+ add(
105
+ "theme",
106
+ config.mode !== "artifact" || documents === "always"
107
+ ? "It runs in the person's light or dark theme."
108
+ : documents === "never"
109
+ ? "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."
110
+ : '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.',
111
+ );
112
+
113
+ add(
114
+ "silent",
115
+ "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.",
116
+ );
117
+ add("network", network);
118
+ // Assets: what isn't opened goes inline (Network: none already says "everything inline").
119
+ const anywhere = (kind: "img" | "font" | "media") =>
120
+ (config.egress.anyHttps ?? []).some((each) => each === kind);
121
+ const passive = (["img", "font", "media"] as const).filter((kind) => !anywhere(kind));
122
+ const listed = passive.some((kind) => opens(config, kind)) || opens(config, "style");
123
+ const anyOpen =
124
+ listed ||
125
+ egressOrigins(config, "script").length > 0 ||
126
+ egressOrigins(config, "connect").length > 0 ||
127
+ passive.length < 3;
128
+ if (anyOpen) {
129
+ const images =
130
+ passive.length === 0
131
+ ? null
132
+ : passive.every((kind) => !opens(config, kind))
133
+ ? "images, icons, fonts and sound go inline (data: URLs or SVG)"
134
+ : "images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)";
135
+ const css = opens(config, "style") ? "other CSS goes in a <style>" : "CSS goes in a <style>";
136
+ const said = [images, css].filter((each) => each !== null).join("; ");
137
+ add("inline-assets", `${said.charAt(0).toUpperCase()}${said.slice(1)}.`);
138
+ }
139
+ if (!tokens.includes("allow-modals")) {
140
+ add(
141
+ "dialogs",
142
+ "alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs.",
143
+ );
144
+ add("print", "window.print() does nothing.");
145
+ }
146
+ if (!tokens.includes("allow-downloads")) {
147
+ add(
148
+ "downloads",
149
+ `Downloads don't work: <a download>, data: and blob: links, saves from script.${declared.has("downloadFile") ? " To give the person a file, ask the app (ui/download-file; with the App SDK, app.downloadFile)." : ""}`,
150
+ );
151
+ }
152
+ if (!tokens.includes("allow-forms")) {
153
+ add(
154
+ "forms",
155
+ 'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.',
156
+ );
157
+ }
158
+ add(
159
+ "link-schemes",
160
+ `mailto:, tel: and sms: links open nothing: show the address or number as text${globals.has("copyText") ? " (with a copy button)" : ""}.`,
161
+ );
162
+ if (!tokens.includes("allow-popups")) {
163
+ add(
164
+ "windows",
165
+ globals.has("openLink") || declared.has("openLink")
166
+ ? "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)."
167
+ : "Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws).",
168
+ );
169
+ }
170
+ add(
171
+ "devices",
172
+ "Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work.",
173
+ );
174
+ add(
175
+ "workers",
176
+ "Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone.",
177
+ );
178
+ add("sound", "Sound (and video with sound) plays only from a click: call play() in its handler.");
179
+ add(
180
+ "address",
181
+ config.isolation === "opaque"
182
+ ? `The ${it}'s address is about:srcdoc: it has no query string or hash to read.`
183
+ : `The ${it}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`,
184
+ );
185
+ add(
186
+ "shell",
187
+ `document.write and document.open throw; nested frames (iframe, object, embed) are removed; full screen and the clipboard are closed${globals.has("copyText") ? " (copy buttons call copyText)" : ""}.`,
188
+ );
189
+ add(
190
+ "storage",
191
+ config.isolation === "opaque"
192
+ ? `State isn't saved: localStorage works but lasts only while the ${it} is on screen, and a reload or another device starts fresh. Keep what matters in the ${it}'s code.`
193
+ : config.storage?.scope !== undefined
194
+ ? `localStorage is kept for ${config.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.`
195
+ : config.storage?.shared === undefined
196
+ ? `localStorage is kept for this ${it} 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.`
197
+ : `localStorage is shared by every ${it} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${it}'s own ("budget:filters", not "filters"), or ${it}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.`,
198
+ );
199
+ // Where the content's errors go (P065): never promise feedback that doesn't come.
200
+ const reports = config.limits.maxErrorReports;
201
+ const rate = (config.capabilities.reportError?.gates ?? [{ rateLimit: { perMinute: 10 } }]).find(
202
+ (gate): gate is { readonly rateLimit: { readonly perMinute: number } } =>
203
+ typeof gate === "object",
204
+ );
205
+ const atMost = rate === undefined ? "" : ` At most ${rate.rateLimit.perMinute} a minute.`;
206
+ add(
207
+ "errors",
208
+ !declared.has("reportError")
209
+ ? "Errors: nothing reports what your code throws, to you or the app: test what you write."
210
+ : config.errors === "agent"
211
+ ? `Errors: what your code throws, and anything the sandbox blocks, comes back to you (at most ${reports} a run), so you can fix it.${atMost}`
212
+ : config.errors === "host"
213
+ ? `Errors: what your code throws, and anything the sandbox blocks, goes to the app (at most ${reports} a run), not to you: test what you write.${atMost}`
214
+ : `Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${reports} a run).${atMost}`,
215
+ );
216
+ return out;
217
+ }
218
+
219
+ /** A byte limit in words: KB under a megabyte, MB above. */
220
+ export function sizeWords(bytes: number): string {
221
+ return bytes < 1_000_000
222
+ ? `${Math.floor(bytes / 1000)} KB`
223
+ : `${Math.round((bytes / 1_048_576) * 10) / 10} MB`;
224
+ }
@@ -108,7 +108,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
108
108
  emit("error", { message, source: "host" });
109
109
  };
110
110
 
111
- let scheme: ColorScheme = mount.scheme;
111
+ // A page the host doesn't theme (`documents: "never"`, P065) is always given light.
112
+ const themeless = config.theme.documents === "never";
113
+ let scheme: ColorScheme = themeless ? "light" : mount.scheme;
112
114
  let height = mount.initialHeightPx ?? 0;
113
115
  let surface: Surface | null = null;
114
116
  let connection: C | null = null;
@@ -323,8 +325,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
323
325
  referrerPolicy: compiled.referrerPolicy,
324
326
  colorScheme: scheme,
325
327
  // An app reports no size: its frame fills the host's container, at least the minimum;
326
- // so does any display the host mounts to fill (its content scrolls inside).
327
- ...(speaks && mount.fill !== true
328
+ // so does any display the host mounts to fill, or whose layout fills (P065): its
329
+ // content scrolls itself.
330
+ ...(speaks && mount.fill !== true && config.layout.kind !== "fill"
328
331
  ? { initialHeightPx: height }
329
332
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
330
333
  });
@@ -443,6 +446,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
443
446
  displayId: mount.displayId,
444
447
  annotations,
445
448
  setTheme(next: ColorScheme) {
449
+ if (themeless) {
450
+ return;
451
+ }
446
452
  scheme = next;
447
453
  surface?.setColorScheme(next);
448
454
  // Read the host's values two frames later: a host that restyles its page for the new
@@ -37,7 +37,7 @@ export interface PresetOptions {
37
37
  readonly theme?: {
38
38
  readonly variables?: Readonly<Record<string, ThemeValue | null>>;
39
39
  readonly event?: string;
40
- readonly documents?: "declared" | "always";
40
+ readonly documents?: "declared" | "always" | "never";
41
41
  };
42
42
  readonly limits?: Partial<Limits>;
43
43
  readonly layout?: Partial<LayoutConfig>;
@@ -48,6 +48,8 @@ export interface PresetOptions {
48
48
  readonly embedder?: EmbedderConfig;
49
49
  /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
50
50
  readonly storage?: StorageConfig;
51
+ /** Where the content's errors go (`PortholeConfig.errors`); `none` drops `reportError`. */
52
+ readonly errors?: "agent" | "host" | "none";
51
53
  }
52
54
 
53
55
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
@@ -156,6 +158,9 @@ function build(
156
158
  const capabilities: { -readonly [N in CapabilityName]?: CapabilityConfig } = {
157
159
  ...BASE_CAPABILITIES,
158
160
  };
161
+ if (options.errors === "none") {
162
+ delete capabilities.reportError;
163
+ }
159
164
  for (const [name, value] of Object.entries(options.capabilities ?? {}) as [
160
165
  CapabilityName,
161
166
  CapabilityConfig | false,
@@ -182,6 +187,7 @@ function build(
182
187
  contentOrigins: options.contentOrigins ?? [],
183
188
  embedder: options.embedder ?? {},
184
189
  ...(options.storage === undefined ? {} : { storage: options.storage }),
190
+ ...(options.errors === undefined ? {} : { errors: options.errors }),
185
191
  });
186
192
  }
187
193
 
@@ -392,7 +392,11 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
392
392
  position: "relative",
393
393
  width: "100%",
394
394
  // A display that fills takes its parent's height (the host sizes the parent, or this box).
395
- ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
395
+ ...(props.fill === true ||
396
+ props.config.mode === "url" ||
397
+ props.config.layout.kind === "fill"
398
+ ? { height: "100%" }
399
+ : {}),
396
400
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
397
401
  ...props.style,
398
402
  }}
@@ -434,7 +438,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
434
438
  // By value: a config or instance made inline (each render) is the same display.
435
439
  const config = useByValue(frame.config);
436
440
  const instance = useByValue(frame.instance);
437
- const fills = config.mode === "url" || frame.fill === true;
441
+ const fills = config.mode === "url" || frame.fill === true || config.layout.kind === "fill";
438
442
 
439
443
  // The latest props, read when the display calls back (never during render).
440
444
  const latest = useEffectEvent(() => frame);
package/src/shell/plan.ts CHANGED
@@ -26,7 +26,7 @@ export interface ShellPlan {
26
26
  /** The window event fired after a theme change. */
27
27
  readonly themeEvent: string;
28
28
  /** Whether a whole document takes the person's scheme: when it declares it, or always. */
29
- readonly themeDocuments: "declared" | "always";
29
+ readonly themeDocuments: "declared" | "always" | "never";
30
30
  /** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
31
31
  readonly storage: "memory" | "native";
32
32
  readonly maxErrorReports: number;
@@ -567,7 +567,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
567
567
  document.body.replaceWith(document.importNode(parsed.body, true));
568
568
  root = null;
569
569
  const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
570
- themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
570
+ themed = plan.themeDocuments !== "never" && (plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || "")));
571
571
  schemeFollows();
572
572
  await runScripts(scripts);
573
573
  announceLoaded();