@botiverse/porthole 0.1.6 → 0.1.8

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.
@@ -1,9 +1,10 @@
1
1
  import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
2
2
  import { BUILT_IN_TOPICS } from "../config/validate.js";
3
- import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
3
+ import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, } from "../compile/policy.js";
4
4
  import { shellPlan } from "../shell/plan.js";
5
5
  import { themeDefault } from "../theme/variables.js";
6
6
  import { GUIDE_EXAMPLES } from "./examples.js";
7
+ import { manualLimits, sizeWords } from "./limits.js";
7
8
  const VARIABLE = /--[a-z0-9]+(?:-[a-z0-9]+)*/gu;
8
9
  function context(config) {
9
10
  const variables = Object.keys(config.theme.variables);
@@ -73,25 +74,6 @@ function network(config) {
73
74
  /** Whether a whole document takes the theme only when it declares support (ThemeConfig.documents). */
74
75
  const optsIn = (config) => config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
75
76
  const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
76
- /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
77
- function forbidden(config, c) {
78
- const tokens = sandboxTokens(config);
79
- const items = ["position: fixed", "full screen"];
80
- if (!tokens.includes("allow-modals")) {
81
- items.push("alert/confirm/prompt");
82
- }
83
- if (!tokens.includes("allow-forms")) {
84
- items.push("forms that submit");
85
- }
86
- if (!tokens.includes("allow-popups")) {
87
- items.push("popups and new windows");
88
- }
89
- if (!tokens.includes("allow-downloads")) {
90
- items.push("downloads");
91
- }
92
- items.push(c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard", "frames (iframe, object, embed)", "WebRTC");
93
- return `Not allowed: ${items.join(", ")}.`;
94
- }
95
77
  function widget(c) {
96
78
  const { config, noun } = c;
97
79
  const { layout, limits } = config;
@@ -102,18 +84,23 @@ function widget(c) {
102
84
  : config.mode === "mcp-app"
103
85
  ? "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."
104
86
  : "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.";
105
- const height = c.declared("resize")
106
- ? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
107
- : "Its height is fixed by the app: keep it short, or let it scroll inside.";
87
+ const stated = manualLimits(config, network(config));
88
+ const said = (id) => stated.find((limit) => limit.id === id)?.text ?? "";
89
+ const fills = layout.kind === "fill";
90
+ const themeless = config.theme.documents === "never";
108
91
  const viewBox = layout.narrowWidthPx + 20;
109
92
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
110
93
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
111
94
  const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
112
- 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)
95
+ return lines(`# ${title}${tool}`, "", what, "", `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", fills
96
+ ? null
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)
113
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."
114
- : null, "", optsIn(config)
115
- ? `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).`
116
- : `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-") === ""
99
+ : null, "", themeless
100
+ ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
101
+ : optsIn(config)
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
+ : `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-") === ""
117
104
  ? null
118
105
  : `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`, c.names("--color-accent") === ""
119
106
  ? null
@@ -127,7 +114,9 @@ function widget(c) {
127
114
  ? null
128
115
  : `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`, otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`, "", exampleFits(c, GUIDE_EXAMPLES.stats)
129
116
  ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
130
- : 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.`), `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"
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
+ ? null
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"
131
120
  ? "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."
132
121
  : 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.", c.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."), exampleFits(c, GUIDE_EXAMPLES.diagram) ? `\n${GUIDE_EXAMPLES.diagram}` : null);
133
122
  }
@@ -157,7 +146,7 @@ function chart(c) {
157
146
  const categorical = Object.keys(config.theme.variables).filter((name) => /^--color-chart-\d+$/u.test(name));
158
147
  const sequential = Object.keys(config.theme.variables).filter((name) => /^--color-chart-seq-\d+$/u.test(name));
159
148
  const libraries = loadsScripts(config)
160
- ? `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.`
149
+ ? `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.`
161
150
  : "No chart library loads here: draw the SVG yourself, from a script, as below.";
162
151
  return lines(`# Charts (a chart is a ${c.noun})`, "", "Pick the form first: a trend over time → line; compare categories → horizontal bars; x against y → scatter; one number → a big number, not a chart; a few numbers → 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.', c.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.", "", libraries, "", categorical.length === 0 ? null : "Colors:", categorical.length === 0
163
152
  ? null
@@ -200,7 +189,7 @@ const ENTRIES = {
200
189
  ? `- 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.`
201
190
  : `- 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")}`,
202
191
  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.`,
203
- 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(),
192
+ reportError: (c) => `- ${manualLimits(c.config, "").find((limit) => limit.id === "errors")?.text ?? ""}`,
204
193
  resize: () => "",
205
194
  };
206
195
  function interactive(c) {
@@ -211,15 +200,17 @@ function interactive(c) {
211
200
  return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
212
201
  })
213
202
  .filter((entry) => entry !== "");
214
- const state = config.isolation === "opaque"
215
- ? `- 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.`
216
- : config.storage?.shared === undefined
217
- ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
218
- : `- 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.`;
203
+ const stated = manualLimits(config, "");
204
+ const stored = stated.find((limit) => limit.id === "storage");
205
+ const state = stored === undefined ? null : `- ${stored.text}`;
206
+ // Without reportError the capability list has no errors entry: say that nothing reports them.
207
+ const errors = c.declared("reportError")
208
+ ? null
209
+ : `- ${stated.find((limit) => limit.id === "errors")?.text ?? ""}`;
219
210
  const none = entries.length === 0
220
211
  ? `- It can only draw: it has no way to send anything to you or the app.`
221
212
  : null;
222
- return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
213
+ return lines(`# Interactive ${noun}s`, "", ...entries, errors, none, state);
223
214
  }
224
215
  const TOPICS = {
225
216
  widget,
@@ -282,5 +273,11 @@ export function generateToolDescription(config) {
282
273
  const read = config.guide.guideToolName === undefined
283
274
  ? ""
284
275
  : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
285
- 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(/ {2,}/gu, " ");
276
+ const where = layout.kind === "fill"
277
+ ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
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)" : ""}`;
279
+ const theme = config.theme.documents === "never"
280
+ ? "always in light (the app doesn't theme it)"
281
+ : "in the person's light or dark theme through CSS variables";
282
+ return `${what} ${where}, ${theme}, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu, " ");
286
283
  }
@@ -0,0 +1,24 @@
1
+ import type { PortholeConfig } from "../config/schema.ts";
2
+ /**
3
+ * The sandbox's limits as the manual states them (P064): each a short line, derived from the
4
+ * config, for what silently changes how content behaves. Every limit has an id, and the
5
+ * conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
6
+ * no browser test proves. What differs by engine is stated as the behavior safe on all of them.
7
+ */
8
+ export declare const LIMIT_IDS: readonly ["layout", "theme", "silent", "network", "inline-assets", "dialogs", "print", "downloads", "forms", "link-schemes", "windows", "devices", "workers", "sound", "address", "shell", "storage", "errors"];
9
+ export type LimitId = (typeof LIMIT_IDS)[number];
10
+ /** Where the manual states a limit: where it runs, the sandbox list, or the interactive topic. */
11
+ export type LimitTopic = "where" | "sandbox" | "interactive";
12
+ export interface ManualLimit {
13
+ readonly id: LimitId;
14
+ readonly text: string;
15
+ readonly topic: LimitTopic;
16
+ }
17
+ /**
18
+ * The limits the manual states for `config`, in the order it states them. `network` is the
19
+ * caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
20
+ * Url mode has no shell and no manual: none.
21
+ */
22
+ export declare function manualLimits(config: PortholeConfig, network: string): ManualLimit[];
23
+ /** A byte limit in words: KB under a megabyte, MB above. */
24
+ export declare function sizeWords(bytes: number): string;
@@ -0,0 +1,160 @@
1
+ import { declaredCapabilities, egressOrigins, sandboxTokens } from "../compile/policy.js";
2
+ import { shellPlan } from "../shell/plan.js";
3
+ /**
4
+ * The sandbox's limits as the manual states them (P064): each a short line, derived from the
5
+ * config, for what silently changes how content behaves. Every limit has an id, and the
6
+ * conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
7
+ * no browser test proves. What differs by engine is stated as the behavior safe on all of them.
8
+ */
9
+ export const LIMIT_IDS = [
10
+ "layout",
11
+ "theme",
12
+ "silent",
13
+ "network",
14
+ "inline-assets",
15
+ "dialogs",
16
+ "print",
17
+ "downloads",
18
+ "forms",
19
+ "link-schemes",
20
+ "windows",
21
+ "devices",
22
+ "workers",
23
+ "sound",
24
+ "address",
25
+ "shell",
26
+ "storage",
27
+ "errors",
28
+ ];
29
+ const TOPICS = {
30
+ layout: "where",
31
+ theme: "where",
32
+ storage: "interactive",
33
+ errors: "interactive",
34
+ silent: "sandbox",
35
+ network: "sandbox",
36
+ "inline-assets": "sandbox",
37
+ dialogs: "sandbox",
38
+ print: "sandbox",
39
+ downloads: "sandbox",
40
+ forms: "sandbox",
41
+ "link-schemes": "sandbox",
42
+ windows: "sandbox",
43
+ devices: "sandbox",
44
+ workers: "sandbox",
45
+ sound: "sandbox",
46
+ address: "sandbox",
47
+ shell: "sandbox",
48
+ };
49
+ const noun = (config) => config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget";
50
+ /** Whether content may load `kind` from somewhere outside (listed origins, or any https site). */
51
+ const opens = (config, kind) => egressOrigins(config, kind).length > 0 ||
52
+ (config.egress.anyHttps ?? []).some((each) => each === kind);
53
+ /**
54
+ * The limits the manual states for `config`, in the order it states them. `network` is the
55
+ * caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
56
+ * Url mode has no shell and no manual: none.
57
+ */
58
+ export function manualLimits(config, network) {
59
+ if (config.mode === "url") {
60
+ return [];
61
+ }
62
+ const tokens = sandboxTokens(config);
63
+ const declared = new Set(declaredCapabilities(config));
64
+ const globals = new Set(shellPlan(config)?.globals ?? []);
65
+ const it = noun(config);
66
+ const out = [];
67
+ const add = (id, text) => {
68
+ if (text !== null) {
69
+ out.push({ id, text, topic: TOPICS[id] });
70
+ }
71
+ };
72
+ // Where it runs (P065): a card that grows with its content, or the whole viewer.
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."
75
+ : declared.has("resize")
76
+ ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
77
+ : "Its height is fixed by the app: keep it short, or let it scroll inside.");
78
+ const documents = config.theme.documents ?? "declared";
79
+ add("theme", config.mode !== "artifact" || documents === "always"
80
+ ? "It runs in the person's light or dark theme."
81
+ : documents === "never"
82
+ ? "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."
83
+ : '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.');
84
+ // What a block does on the page, and where it is reported: as the Errors line says (#4).
85
+ const blocked = "a blocked script, style or image just doesn't load and a blocked fetch rejects";
86
+ add("silent", !declared.has("reportError")
87
+ ? "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."
88
+ : config.errors === "agent"
89
+ ? `What the sandbox blocks shows nothing on the page: ${blocked}. Each block comes back to you as an error, so check them.`
90
+ : config.errors === "host"
91
+ ? `What the sandbox blocks shows nothing on the page: ${blocked}. The app is told of each block, but you aren't: test what you write.`
92
+ : `What the sandbox blocks shows nothing on the page: ${blocked}. Each block is reported to the app.`);
93
+ add("network", network);
94
+ // Assets: what isn't opened goes inline (Network: none already says "everything inline").
95
+ const anywhere = (kind) => (config.egress.anyHttps ?? []).some((each) => each === kind);
96
+ const passive = ["img", "font", "media"].filter((kind) => !anywhere(kind));
97
+ const listed = passive.some((kind) => opens(config, kind)) || opens(config, "style");
98
+ const anyOpen = listed ||
99
+ egressOrigins(config, "script").length > 0 ||
100
+ egressOrigins(config, "connect").length > 0 ||
101
+ passive.length < 3;
102
+ if (anyOpen) {
103
+ const images = passive.length === 0
104
+ ? null
105
+ : passive.every((kind) => !opens(config, kind))
106
+ ? "images, icons, fonts and sound go inline (data: URLs or SVG)"
107
+ : "images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)";
108
+ const css = opens(config, "style") ? "other CSS goes in a <style>" : "CSS goes in a <style>";
109
+ const said = [images, css].filter((each) => each !== null).join("; ");
110
+ add("inline-assets", `${said.charAt(0).toUpperCase()}${said.slice(1)}.`);
111
+ }
112
+ if (!tokens.includes("allow-modals")) {
113
+ add("dialogs", "alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs.");
114
+ add("print", "window.print() does nothing.");
115
+ }
116
+ if (!tokens.includes("allow-downloads")) {
117
+ add("downloads", `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)." : ""}`);
118
+ }
119
+ if (!tokens.includes("allow-forms")) {
120
+ add("forms", 'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.');
121
+ }
122
+ add("link-schemes", `mailto:, tel: and sms: links open nothing: show the address or number as text${globals.has("copyText") ? " (with a copy button)" : ""}.`);
123
+ if (!tokens.includes("allow-popups")) {
124
+ add("windows", globals.has("openLink") || declared.has("openLink")
125
+ ? "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)."
126
+ : "Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws).");
127
+ }
128
+ add("devices", "Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work.");
129
+ add("workers", "Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone.");
130
+ add("sound", "Sound (and video with sound) plays only from a click: call play() in its handler.");
131
+ add("address", config.isolation === "opaque"
132
+ ? `The ${it}'s address is about:srcdoc: it has no query string or hash to read.`
133
+ : `The ${it}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`);
134
+ add("shell", `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)" : ""}.`);
135
+ add("storage", config.isolation === "opaque"
136
+ ? `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.`
137
+ : config.storage?.scope !== undefined
138
+ ? `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.`
139
+ : config.storage?.shared === undefined
140
+ ? `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.`
141
+ : `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.`);
142
+ // Where the content's errors go (P065): never promise feedback that doesn't come.
143
+ const reports = config.limits.maxErrorReports;
144
+ const rate = (config.capabilities.reportError?.gates ?? [{ rateLimit: { perMinute: 10 } }]).find((gate) => typeof gate === "object");
145
+ const atMost = rate === undefined ? "" : ` At most ${rate.rateLimit.perMinute} a minute.`;
146
+ add("errors", !declared.has("reportError")
147
+ ? "Errors: nothing reports what your code throws, to you or the app: test what you write."
148
+ : config.errors === "agent"
149
+ ? `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}`
150
+ : config.errors === "host"
151
+ ? `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}`
152
+ : `Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${reports} a run).${atMost}`);
153
+ return out;
154
+ }
155
+ /** A byte limit in words: KB under a megabyte, MB above. */
156
+ export function sizeWords(bytes) {
157
+ return bytes < 1_000_000
158
+ ? `${Math.floor(bytes / 1000)} KB`
159
+ : `${Math.round((bytes / 1_048_576) * 10) / 10} MB`;
160
+ }
@@ -67,7 +67,9 @@ export function createHost(config, options = {}) {
67
67
  emit("hostError", { message, kind });
68
68
  emit("error", { message, source: "host" });
69
69
  };
70
- let scheme = mount.scheme;
70
+ // A page the host doesn't theme (`documents: "never"`, P065) is always given light.
71
+ const themeless = config.theme.documents === "never";
72
+ let scheme = themeless ? "light" : mount.scheme;
71
73
  let height = mount.initialHeightPx ?? 0;
72
74
  let surface = null;
73
75
  let connection = null;
@@ -271,8 +273,9 @@ export function createHost(config, options = {}) {
271
273
  referrerPolicy: compiled.referrerPolicy,
272
274
  colorScheme: scheme,
273
275
  // An app reports no size: its frame fills the host's container, at least the minimum;
274
- // so does any display the host mounts to fill (its content scrolls inside).
275
- ...(speaks && mount.fill !== true
276
+ // so does any display the host mounts to fill, or whose layout fills (P065): its
277
+ // content scrolls itself.
278
+ ...(speaks && mount.fill !== true && config.layout.kind !== "fill"
276
279
  ? { initialHeightPx: height }
277
280
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
278
281
  });
@@ -386,6 +389,9 @@ export function createHost(config, options = {}) {
386
389
  displayId: mount.displayId,
387
390
  annotations,
388
391
  setTheme(next) {
392
+ if (themeless) {
393
+ return;
394
+ }
389
395
  scheme = next;
390
396
  surface?.setColorScheme(next);
391
397
  // Read the host's values two frames later: a host that restyles its page for the new
@@ -21,7 +21,7 @@ export interface PresetOptions {
21
21
  readonly theme?: {
22
22
  readonly variables?: Readonly<Record<string, ThemeValue | null>>;
23
23
  readonly event?: string;
24
- readonly documents?: "declared" | "always";
24
+ readonly documents?: "declared" | "always" | "never";
25
25
  };
26
26
  readonly limits?: Partial<Limits>;
27
27
  readonly layout?: Partial<LayoutConfig>;
@@ -32,6 +32,8 @@ export interface PresetOptions {
32
32
  readonly embedder?: EmbedderConfig;
33
33
  /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
34
34
  readonly storage?: StorageConfig;
35
+ /** Where the content's errors go (`PortholeConfig.errors`); `none` drops `reportError`. */
36
+ readonly errors?: "agent" | "host" | "none";
35
37
  }
36
38
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
37
39
  export interface UrlPresetOptions {
@@ -83,6 +83,9 @@ function build(mode, defaults, options) {
83
83
  const capabilities = {
84
84
  ...BASE_CAPABILITIES,
85
85
  };
86
+ if (options.errors === "none") {
87
+ delete capabilities.reportError;
88
+ }
86
89
  for (const [name, value] of Object.entries(options.capabilities ?? {})) {
87
90
  if (value === false) {
88
91
  delete capabilities[name];
@@ -107,6 +110,7 @@ function build(mode, defaults, options) {
107
110
  contentOrigins: options.contentOrigins ?? [],
108
111
  embedder: options.embedder ?? {},
109
112
  ...(options.storage === undefined ? {} : { storage: options.storage }),
113
+ ...(options.errors === undefined ? {} : { errors: options.errors }),
110
114
  });
111
115
  }
112
116
  export const presets = {
@@ -147,7 +147,11 @@ export function PortholeFrame(props) {
147
147
  position: "relative",
148
148
  width: "100%",
149
149
  // A display that fills takes its parent's height (the host sizes the parent, or this box).
150
- ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
150
+ ...(props.fill === true ||
151
+ props.config.mode === "url" ||
152
+ props.config.layout.kind === "fill"
153
+ ? { height: "100%" }
154
+ : {}),
151
155
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
152
156
  ...props.style,
153
157
  }, children: state === "running" ? (_jsx(Mount, { frame: props, page: page, initialHeightPx: height, placeholder: placeholder, run: run }, String(props.generation ?? 0))) : (placeholder({ state, title: props.title, run })) }));
@@ -163,7 +167,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
163
167
  // By value: a config or instance made inline (each render) is the same display.
164
168
  const config = useByValue(frame.config);
165
169
  const instance = useByValue(frame.instance);
166
- const fills = config.mode === "url" || frame.fill === true;
170
+ const fills = config.mode === "url" || frame.fill === true || config.layout.kind === "fill";
167
171
  // The latest props, read when the display calls back (never during render).
168
172
  const latest = useEffectEvent(() => frame);
169
173
  const notify = useEffectEvent((event, detail) => {
@@ -27,7 +27,7 @@ export interface ShellPlan {
27
27
  /** The window event fired after a theme change. */
28
28
  readonly themeEvent: string;
29
29
  /** Whether a whole document takes the person's scheme: when it declares it, or always. */
30
- readonly themeDocuments: "declared" | "always";
30
+ readonly themeDocuments: "declared" | "always" | "never";
31
31
  /** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
32
32
  readonly storage: "memory" | "native";
33
33
  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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -86,9 +86,11 @@ export interface ThemeConfig {
86
86
  * Whether a whole document (an artifact page) takes the person's light or dark scheme:
87
87
  * `declared` (default): only a page that declares it supports both
88
88
  * (`<meta name="color-scheme" content="light dark">`); any other page shows as its author
89
- * styled it, in light. `always`: every page. The variables are there either way.
89
+ * styled it, in light. `always`: every page. `never` (pages only, P065): the host has no
90
+ * theme for it: it always gets the light values, its frame says light, and no theme change
91
+ * reaches it. The variables are there in every case.
90
92
  */
91
- readonly documents?: "declared" | "always";
93
+ readonly documents?: "declared" | "always" | "never";
92
94
  }
93
95
 
94
96
  export interface Limits {
@@ -143,6 +145,13 @@ export interface LayoutConfig {
143
145
  readonly narrowWidthPx: number;
144
146
  /** The widest the frame is (a desktop column). */
145
147
  readonly wideWidthPx: number;
148
+ /**
149
+ * How the frame sits in the host (P065). `flow` (default): a card in a column, as tall as its
150
+ * content (up to `maxHeightPx`). `fill`: it fills the viewer (the page itself): as tall as its
151
+ * container, the content scrolling itself, `position: fixed` and `sticky` working. Every mount
152
+ * then fills (`MountOptions.fill`), and the manual says so.
153
+ */
154
+ readonly kind?: "flow" | "fill";
146
155
  }
147
156
 
148
157
  export interface GuideConfig {
@@ -193,9 +202,23 @@ export interface PortholeConfig {
193
202
  * "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
194
203
  */
195
204
  readonly storage?: StorageConfig;
205
+ /**
206
+ * Where the content's own errors go, for the manual (P065): `agent`, back to the agent that
207
+ * wrote it; `host`, to the app only (the agent never sees them); `none`, nowhere (the config
208
+ * declares no `reportError`). Absent: the manual says they are reported to the app.
209
+ */
210
+ readonly errors?: "agent" | "host" | "none";
196
211
  }
197
212
 
198
213
  export interface StorageConfig {
199
- /** What the displays sharing one origin have in common, as a noun ("conversation"). */
214
+ /**
215
+ * What the displays sharing one origin at once have in common, as a noun ("conversation"):
216
+ * the manual tells authors to prefix their keys.
217
+ */
200
218
  readonly shared?: string;
219
+ /**
220
+ * What one display's storage is kept for, when only one display uses its origin at a time, in
221
+ * words ("this artifact, across its versions", P065): the manual says so, with no prefix advice.
222
+ */
223
+ readonly scope?: string;
201
224
  }
@@ -292,6 +292,7 @@ export function validateConfig(input: unknown): PortholeConfig {
292
292
  "sandbox",
293
293
  "permissions",
294
294
  "storage",
295
+ "errors",
295
296
  ],
296
297
  "config",
297
298
  );
@@ -360,9 +361,16 @@ export function validateConfig(input: unknown): PortholeConfig {
360
361
  if (
361
362
  theme.documents !== undefined &&
362
363
  theme.documents !== "declared" &&
363
- theme.documents !== "always"
364
+ theme.documents !== "always" &&
365
+ theme.documents !== "never"
364
366
  ) {
365
- fail("config.theme.documents", 'must be "declared" or "always"');
367
+ fail("config.theme.documents", 'must be "declared", "always" or "never"');
368
+ }
369
+ if (theme.documents === "never" && config.mode !== "artifact") {
370
+ fail(
371
+ "config.theme.documents",
372
+ '"never" is for whole pages: a fragment takes the theme it is drawn in',
373
+ );
366
374
  }
367
375
  const variables = object(theme.variables, "config.theme.variables");
368
376
  for (const [name, value] of Object.entries(variables)) {
@@ -414,7 +422,10 @@ export function validateConfig(input: unknown): PortholeConfig {
414
422
  }
415
423
 
416
424
  const layout = object(config.layout, "config.layout");
417
- only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx"], "config.layout");
425
+ only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx", "kind"], "config.layout");
426
+ if (layout.kind !== undefined && layout.kind !== "flow" && layout.kind !== "fill") {
427
+ fail("config.layout.kind", 'must be "flow" or "fill"');
428
+ }
418
429
  for (const name of ["minWidthPx", "narrowWidthPx", "wideWidthPx"]) {
419
430
  positive(layout[name], `config.layout.${name}`, 10_000);
420
431
  }
@@ -494,7 +505,22 @@ export function validateConfig(input: unknown): PortholeConfig {
494
505
 
495
506
  if (config.storage !== undefined) {
496
507
  const storage = object(config.storage, "config.storage");
497
- only(storage, ["shared"], "config.storage");
508
+ only(storage, ["shared", "scope"], "config.storage");
509
+ if (storage.shared !== undefined && storage.scope !== undefined) {
510
+ fail(
511
+ "config.storage",
512
+ "shared (several displays at once) or scope (one at a time), not both",
513
+ );
514
+ }
515
+ if (
516
+ storage.scope !== undefined &&
517
+ (typeof storage.scope !== "string" || !/^[a-z][a-z ,'-]{0,59}$/u.test(storage.scope))
518
+ ) {
519
+ fail(
520
+ "config.storage.scope",
521
+ 'must be short lowercase words ("this artifact, across its versions")',
522
+ );
523
+ }
498
524
  if (config.isolation !== "domain") {
499
525
  fail("config.storage", "only domain isolation keeps storage across displays");
500
526
  }
@@ -506,6 +532,20 @@ export function validateConfig(input: unknown): PortholeConfig {
506
532
  }
507
533
  }
508
534
 
535
+ if (config.errors !== undefined) {
536
+ if (config.errors !== "agent" && config.errors !== "host" && config.errors !== "none") {
537
+ fail("config.errors", 'must be "agent", "host" or "none"');
538
+ }
539
+ if ((config.errors === "none") !== (capabilities.reportError === undefined)) {
540
+ fail(
541
+ "config.errors",
542
+ config.errors === "none"
543
+ ? '"none" means no reportError: drop the capability'
544
+ : `${JSON.stringify(config.errors)} needs the reportError capability, which carries them`,
545
+ );
546
+ }
547
+ }
548
+
509
549
  if (config.permissions !== undefined) {
510
550
  if (config.mode !== "url") {
511
551
  fail("config.permissions", "only url mode may be delegated features");
@@ -40,6 +40,8 @@ export interface LaunchSpec {
40
40
  readonly scheme?: ColorScheme;
41
41
  /** The frame's width, px. */
42
42
  readonly widthPx?: number;
43
+ /** The height of the box the frame sits in, px: for a layout that fills it (`layout.kind`). */
44
+ readonly heightPx?: number;
43
45
  /** The host's live values for theme variables, per scheme (as `HostOptions.theme` answers). */
44
46
  readonly hostTheme?: {
45
47
  readonly light?: Readonly<Record<string, string>>;
@@ -34,7 +34,8 @@ export interface Probe {
34
34
  readonly id: string;
35
35
  /**
36
36
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
37
- * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
37
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard, 14
38
+ * for the manual's claims about the sandbox (`LIMIT_CHECKS`).
38
39
  */
39
40
  readonly item: number;
40
41
  readonly title: string;