@botiverse/porthole 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +68 -3
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { capabilitySpec, type CapabilityName, type Gate } from "../config/capabilities.ts";
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
|
+
import { BUILT_IN_TOPICS } from "../config/validate.ts";
|
|
4
|
+
import {
|
|
5
|
+
declaredCapabilities,
|
|
6
|
+
effectiveGates,
|
|
7
|
+
egressOrigins,
|
|
8
|
+
sandboxTokens,
|
|
9
|
+
type EgressKind,
|
|
10
|
+
} from "../compile/policy.ts";
|
|
11
|
+
import { shellPlan } from "../shell/plan.ts";
|
|
12
|
+
import { themeDefault } from "../theme/variables.ts";
|
|
13
|
+
import { GUIDE_EXAMPLES } from "./examples.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The agent manual and the tool description, generated from the config (PRINCIPLE 3, 10): the
|
|
17
|
+
* theme variables it teaches are the ones the shell injects, the globals are the ones the shell
|
|
18
|
+
* defines, the origins are the CSP's, the limits are the config's. A sentence that needs a
|
|
19
|
+
* variable the config doesn't declare is left out, never taught. Each topic is written when asked
|
|
20
|
+
* for, not when the module loads.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type BuiltInTopic = (typeof BUILT_IN_TOPICS)[number];
|
|
24
|
+
|
|
25
|
+
/** A topic name: a built-in one, or one of the config's own (`guide.topics`). */
|
|
26
|
+
export type GuideTopic = BuiltInTopic | (string & {});
|
|
27
|
+
|
|
28
|
+
interface Context {
|
|
29
|
+
readonly config: PortholeConfig;
|
|
30
|
+
/** What the content is called: widget, page or view. */
|
|
31
|
+
readonly noun: string;
|
|
32
|
+
readonly has: (...variables: string[]) => boolean;
|
|
33
|
+
/** `text` when every variable it names is declared, else null (left out). */
|
|
34
|
+
readonly when: (text: string) => string | null;
|
|
35
|
+
readonly names: (prefix: string, except?: readonly string[]) => string;
|
|
36
|
+
readonly declared: (name: CapabilityName) => boolean;
|
|
37
|
+
/** Whether the shell defines `name` as a global for content. */
|
|
38
|
+
readonly global: (name: string) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const VARIABLE = /--[a-z0-9]+(?:-[a-z0-9]+)*/gu;
|
|
42
|
+
|
|
43
|
+
function context(config: PortholeConfig): Context {
|
|
44
|
+
const variables = Object.keys(config.theme.variables);
|
|
45
|
+
const has = (...names: string[]) => names.every((name) => variables.includes(name));
|
|
46
|
+
const declared = new Set(declaredCapabilities(config));
|
|
47
|
+
const globals = new Set(shellPlan(config)?.globals ?? []);
|
|
48
|
+
return {
|
|
49
|
+
config,
|
|
50
|
+
noun: config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget",
|
|
51
|
+
has,
|
|
52
|
+
when: (text) => (has(...(text.match(VARIABLE) ?? [])) ? text : null),
|
|
53
|
+
names: (prefix, except = []) =>
|
|
54
|
+
variables
|
|
55
|
+
.filter(
|
|
56
|
+
(name) => name.startsWith(prefix) && !except.some((other) => name.startsWith(other)),
|
|
57
|
+
)
|
|
58
|
+
.join(", "),
|
|
59
|
+
declared: (name) => declared.has(name),
|
|
60
|
+
global: (name) => globals.has(name),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lines, without those left out (null); "" is a blank line, and blank lines never repeat. */
|
|
65
|
+
const lines = (...each: (string | null)[]): string =>
|
|
66
|
+
each
|
|
67
|
+
.filter((line) => line !== null)
|
|
68
|
+
.join("\n")
|
|
69
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
70
|
+
.trim();
|
|
71
|
+
|
|
72
|
+
const KIND_WORDS: Readonly<Record<EgressKind, string>> = {
|
|
73
|
+
script: "scripts",
|
|
74
|
+
style: "styles",
|
|
75
|
+
font: "fonts",
|
|
76
|
+
img: "images",
|
|
77
|
+
media: "audio and video",
|
|
78
|
+
connect: "data (fetch, WebSocket)",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** What content may load, in prose, from the same origins the CSP lists. */
|
|
82
|
+
function network(config: PortholeConfig): string {
|
|
83
|
+
const kinds: EgressKind[] = ["script", "style", "font", "img", "media", "connect"];
|
|
84
|
+
const open = kinds
|
|
85
|
+
.map((kind) => [kind, egressOrigins(config, kind)] as const)
|
|
86
|
+
.filter(([, origins]) => origins.length > 0);
|
|
87
|
+
if (open.length === 0) {
|
|
88
|
+
return "Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";
|
|
89
|
+
}
|
|
90
|
+
// Origins that serve the same kinds go together: "scripts, styles and data from a, b; fonts from c".
|
|
91
|
+
const byOrigin = new Map<string, EgressKind[]>();
|
|
92
|
+
for (const [kind, origins] of open) {
|
|
93
|
+
for (const origin of origins) {
|
|
94
|
+
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), kind]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const groups = new Map<string, string[]>();
|
|
98
|
+
for (const [origin, each] of byOrigin) {
|
|
99
|
+
const words = each.map((kind) => KIND_WORDS[kind]);
|
|
100
|
+
const key =
|
|
101
|
+
words.length === 1
|
|
102
|
+
? (words[0] ?? "")
|
|
103
|
+
: `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
104
|
+
groups.set(key, [...(groups.get(key) ?? []), origin]);
|
|
105
|
+
}
|
|
106
|
+
const parts = [...groups].map(([words, origins]) => `${words} from ${origins.join(", ")}`);
|
|
107
|
+
return `Network: only ${parts.join("; ")}. Anything else fails.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const loadsScripts = (config: PortholeConfig): boolean =>
|
|
111
|
+
egressOrigins(config, "script").length > 0;
|
|
112
|
+
|
|
113
|
+
/** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
|
|
114
|
+
function forbidden(config: PortholeConfig): string {
|
|
115
|
+
const tokens = sandboxTokens(config);
|
|
116
|
+
const items = ["position: fixed", "full screen"];
|
|
117
|
+
if (!tokens.includes("allow-modals")) {
|
|
118
|
+
items.push("alert/confirm/prompt");
|
|
119
|
+
}
|
|
120
|
+
if (!tokens.includes("allow-forms")) {
|
|
121
|
+
items.push("forms that submit");
|
|
122
|
+
}
|
|
123
|
+
if (!tokens.includes("allow-popups")) {
|
|
124
|
+
items.push("popups and new windows");
|
|
125
|
+
}
|
|
126
|
+
if (!tokens.includes("allow-downloads")) {
|
|
127
|
+
items.push("downloads");
|
|
128
|
+
}
|
|
129
|
+
items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
|
|
130
|
+
return `Not allowed: ${items.join(", ")}.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function widget(c: Context): string {
|
|
134
|
+
const { config, noun } = c;
|
|
135
|
+
const { layout, limits } = config;
|
|
136
|
+
const title = noun === "page" ? "Pages" : noun === "view" ? "Views" : "Widgets";
|
|
137
|
+
const tool = config.guide.toolName === undefined ? "" : ` (${config.guide.toolName})`;
|
|
138
|
+
const what =
|
|
139
|
+
config.mode === "artifact"
|
|
140
|
+
? "What you pass: a whole HTML document (<!doctype html>, <head>, <body>). It is written into its frame once all of it has arrived, then its scripts run in order."
|
|
141
|
+
: config.mode === "mcp-app"
|
|
142
|
+
? "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."
|
|
143
|
+
: "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.";
|
|
144
|
+
const height = c.declared("resize")
|
|
145
|
+
? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
|
|
146
|
+
: "Its height is fixed by the app: keep it short, or let it scroll inside.";
|
|
147
|
+
const viewBox = layout.narrowWidthPx + 20;
|
|
148
|
+
const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
|
|
149
|
+
const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
|
|
150
|
+
const textSize = themeDefault(
|
|
151
|
+
config.theme.variables["--font-text-md-size"] ?? "14px",
|
|
152
|
+
"light",
|
|
153
|
+
).replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
|
|
154
|
+
|
|
155
|
+
return lines(
|
|
156
|
+
`# ${title}${tool}`,
|
|
157
|
+
"",
|
|
158
|
+
what,
|
|
159
|
+
"",
|
|
160
|
+
`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} It runs in the person's light or dark theme, and again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
|
|
161
|
+
"",
|
|
162
|
+
"Sizes that read on a phone:",
|
|
163
|
+
c.when(
|
|
164
|
+
`- 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.`,
|
|
165
|
+
),
|
|
166
|
+
`- 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).`,
|
|
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.`,
|
|
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, …).",
|
|
169
|
+
"- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
|
|
170
|
+
"",
|
|
171
|
+
`${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
|
|
172
|
+
loadsScripts(config)
|
|
173
|
+
? "\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."
|
|
174
|
+
: null,
|
|
175
|
+
"",
|
|
176
|
+
`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).`,
|
|
177
|
+
c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`,
|
|
178
|
+
c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`,
|
|
179
|
+
c.names("--color-border-") === ""
|
|
180
|
+
? null
|
|
181
|
+
: `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`,
|
|
182
|
+
c.names("--color-accent") === ""
|
|
183
|
+
? null
|
|
184
|
+
: `- Accent: ${[
|
|
185
|
+
c.when("--color-accent (a button)"),
|
|
186
|
+
c.when("--color-accent-soft (a selected item's fill)"),
|
|
187
|
+
c.when("in a figure, --color-chart-1 (topic chart)"),
|
|
188
|
+
]
|
|
189
|
+
.filter((part) => part !== null)
|
|
190
|
+
.join("; ")}`,
|
|
191
|
+
c.names("--font-") === "" ? null : `- Type: ${c.names("--font-")}`,
|
|
192
|
+
c.names("--border-") === "" && c.names("--shadow-") === ""
|
|
193
|
+
? null
|
|
194
|
+
: `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`,
|
|
195
|
+
otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`,
|
|
196
|
+
"",
|
|
197
|
+
exampleFits(c, GUIDE_EXAMPLES.stats)
|
|
198
|
+
? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
|
|
199
|
+
: null,
|
|
200
|
+
c.when(
|
|
201
|
+
`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`,
|
|
202
|
+
),
|
|
203
|
+
`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.`,
|
|
204
|
+
config.mode === "inline"
|
|
205
|
+
? "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."
|
|
206
|
+
: null,
|
|
207
|
+
"",
|
|
208
|
+
"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.",
|
|
209
|
+
`- 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.`,
|
|
210
|
+
"- 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.",
|
|
211
|
+
c.when(
|
|
212
|
+
"- 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.",
|
|
213
|
+
),
|
|
214
|
+
exampleFits(c, GUIDE_EXAMPLES.diagram) ? `\n${GUIDE_EXAMPLES.diagram}` : null,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Variables outside the groups the colors list names. */
|
|
219
|
+
function otherVariables(c: Context): string {
|
|
220
|
+
const grouped = [
|
|
221
|
+
"--color-background-",
|
|
222
|
+
"--color-text-",
|
|
223
|
+
"--color-border-",
|
|
224
|
+
"--color-ring-",
|
|
225
|
+
"--color-accent",
|
|
226
|
+
"--color-chart-",
|
|
227
|
+
"--font-",
|
|
228
|
+
"--border-",
|
|
229
|
+
"--shadow-",
|
|
230
|
+
];
|
|
231
|
+
return Object.keys(c.config.theme.variables)
|
|
232
|
+
.filter((name) => !grouped.some((prefix) => name.startsWith(prefix)))
|
|
233
|
+
.join(", ");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether an example uses only declared variables and loads nothing. */
|
|
237
|
+
function exampleFits(c: Context, example: string): boolean {
|
|
238
|
+
return c.has(...(example.match(VARIABLE) ?? [])) && !/https?:\/\//u.test(example);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function chart(c: Context): string {
|
|
242
|
+
const { config } = c;
|
|
243
|
+
const categorical = Object.keys(config.theme.variables).filter((name) =>
|
|
244
|
+
/^--color-chart-\d+$/u.test(name),
|
|
245
|
+
);
|
|
246
|
+
const sequential = Object.keys(config.theme.variables).filter((name) =>
|
|
247
|
+
/^--color-chart-seq-\d+$/u.test(name),
|
|
248
|
+
);
|
|
249
|
+
const libraries = loadsScripts(config)
|
|
250
|
+
? `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.`
|
|
251
|
+
: "No chart library loads here: draw the SVG yourself, from a script, as below.";
|
|
252
|
+
return lines(
|
|
253
|
+
`# Charts (a chart is a ${c.noun})`,
|
|
254
|
+
"",
|
|
255
|
+
"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.",
|
|
256
|
+
"",
|
|
257
|
+
"The look:",
|
|
258
|
+
'- 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.',
|
|
259
|
+
c.when(
|
|
260
|
+
"- 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.",
|
|
261
|
+
),
|
|
262
|
+
"- Values on the marks or a light axis, not both; label only the points the finding rests on. Bars start at zero.",
|
|
263
|
+
"- Write the numbers once, as rows, and draw marks, labels and readout from them.",
|
|
264
|
+
"",
|
|
265
|
+
"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.",
|
|
266
|
+
"",
|
|
267
|
+
libraries,
|
|
268
|
+
"",
|
|
269
|
+
categorical.length === 0 ? null : "Colors:",
|
|
270
|
+
categorical.length === 0
|
|
271
|
+
? null
|
|
272
|
+
: `- Series of different kinds: ${categorical[0]} … ${categorical.at(-1)}, in this order, never cycled, at most ${categorical.length} (fold the rest into "Other"); a series keeps its color when a filter hides others.`,
|
|
273
|
+
sequential.length === 0
|
|
274
|
+
? null
|
|
275
|
+
: `- Magnitude (a heatmap): ${sequential[0]} … ${sequential.at(-1)}, 1 nearest zero.${c.has("--color-chart-neg", "--color-chart-mid", "--color-chart-pos") ? " Above or below a midpoint: --color-chart-neg, --color-chart-mid, --color-chart-pos." : ""}`,
|
|
276
|
+
c.when(
|
|
277
|
+
"- Never a rainbow. Status (good, bad) uses --color-text-success, --color-text-warning, --color-text-danger with a word, never as a series color.",
|
|
278
|
+
),
|
|
279
|
+
c.when(
|
|
280
|
+
"- Text is never in a series color: --color-text-primary or --color-text-secondary; the mark beside it carries identity.",
|
|
281
|
+
),
|
|
282
|
+
"",
|
|
283
|
+
c.when(
|
|
284
|
+
"Marks: 2 px lines, bars with a 2 px gap; grid lines --color-border-tertiary, 1 px; one y axis (two scales: two charts); the legend in HTML.",
|
|
285
|
+
),
|
|
286
|
+
"",
|
|
287
|
+
'Reading values: a readout line in HTML that a tap on a column fills (each column a hit area the plot\'s height), the latest values shown before any tap; a "Table" toggle for the numbers.',
|
|
288
|
+
exampleFits(c, GUIDE_EXAMPLES.line)
|
|
289
|
+
? `\nA line chart drawn at the frame's width, its numbers on a tap:\n${GUIDE_EXAMPLES.line}`
|
|
290
|
+
: null,
|
|
291
|
+
exampleFits(c, GUIDE_EXAMPLES.bars)
|
|
292
|
+
? `\nBars in HTML, under a title like the line chart's:\n${GUIDE_EXAMPLES.bars}`
|
|
293
|
+
: null,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** How a capability's gates read to the author: one sentence each. */
|
|
298
|
+
function gateWords(gates: readonly Gate[], effect: string): string {
|
|
299
|
+
const sentences: string[] = [];
|
|
300
|
+
if (gates.includes("confirm")) {
|
|
301
|
+
sentences.push(
|
|
302
|
+
`The app shows the person ${effect} first, and nothing happens unless they confirm.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (gates.includes("user-activation")) {
|
|
306
|
+
sentences.push("Call it from a click (it needs one).");
|
|
307
|
+
}
|
|
308
|
+
for (const gate of gates) {
|
|
309
|
+
if (typeof gate === "object") {
|
|
310
|
+
sentences.push(`At most ${gate.rateLimit.perMinute} a minute.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return sentences.join(" ");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Gate[]) => string } =
|
|
317
|
+
{
|
|
318
|
+
sendPrompt: (c, gates) =>
|
|
319
|
+
c.global("sendPrompt")
|
|
320
|
+
? `- sendPrompt(text): asks to send text to you as the person's message (at most ${c.config.limits.maxPromptChars.toLocaleString("en-US")} characters). Use it for "explain this point", "try these settings". ${gateWords(gates, "the whole text")} It returns a promise: true if it was sent.`
|
|
321
|
+
: `- Messages: the MCP Apps request ui/message (with the App SDK, app.sendMessage) asks to send text to you as the person's message. ${gateWords(gates, "the whole text")}`,
|
|
322
|
+
openLink: (c, gates) =>
|
|
323
|
+
c.global("openLink")
|
|
324
|
+
? `- openLink(url): asks to open an http(s) link in a new tab; a click on an <a href> does the same for you. ${gateWords(gates, "the link")} It returns a promise: true if it opened.`
|
|
325
|
+
: `- Links: the MCP Apps request ui/open-link (with the App SDK, app.openLink) asks to open an http(s) link in a new tab. ${gateWords(gates, "the link")}`,
|
|
326
|
+
downloadFile: (_c, gates) =>
|
|
327
|
+
`- Downloads: the MCP Apps request ui/download-file (with the App SDK, app.downloadFile). ${gateWords(gates, "the file")}`,
|
|
328
|
+
callTool: (_c, gates) =>
|
|
329
|
+
`- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${gateWords(gates, "the call")}`,
|
|
330
|
+
readData: (_c, gates) =>
|
|
331
|
+
`- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
|
|
332
|
+
setModelContext: (_c, gates) =>
|
|
333
|
+
`- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
|
|
334
|
+
reportError: (c, gates) =>
|
|
335
|
+
`- 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(),
|
|
336
|
+
resize: () => "",
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
function interactive(c: Context): string {
|
|
340
|
+
const { config, noun } = c;
|
|
341
|
+
const entries = declaredCapabilities(config)
|
|
342
|
+
.map((name) => {
|
|
343
|
+
const own = config.capabilities[name]?.guide;
|
|
344
|
+
return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
|
|
345
|
+
})
|
|
346
|
+
.filter((entry) => entry !== "");
|
|
347
|
+
const state =
|
|
348
|
+
config.isolation === "opaque"
|
|
349
|
+
? `- 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.`
|
|
350
|
+
: `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
|
|
351
|
+
const none =
|
|
352
|
+
entries.length === 0
|
|
353
|
+
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
354
|
+
: null;
|
|
355
|
+
return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const TOPICS: Readonly<Record<BuiltInTopic, (c: Context) => string>> = {
|
|
359
|
+
widget,
|
|
360
|
+
chart,
|
|
361
|
+
interactive,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** The manual for `topics`, in the order asked (all built-in topics by default). */
|
|
365
|
+
export function generateGuide(config: PortholeConfig, topics?: readonly GuideTopic[]): string {
|
|
366
|
+
const c = context(config);
|
|
367
|
+
const own = config.guide.topics ?? {};
|
|
368
|
+
return (topics ?? BUILT_IN_TOPICS)
|
|
369
|
+
.map((topic) => {
|
|
370
|
+
if (Object.hasOwn(TOPICS, topic)) {
|
|
371
|
+
return TOPICS[topic as BuiltInTopic](c);
|
|
372
|
+
}
|
|
373
|
+
const text = own[topic];
|
|
374
|
+
if (text === undefined) {
|
|
375
|
+
throw new RangeError(`No guide topic ${JSON.stringify(topic)}`);
|
|
376
|
+
}
|
|
377
|
+
return text;
|
|
378
|
+
})
|
|
379
|
+
.join("\n\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The description of the host's show tool, for the agent's tool list. */
|
|
383
|
+
export function generateToolDescription(config: PortholeConfig): string {
|
|
384
|
+
const c = context(config);
|
|
385
|
+
const { layout } = config;
|
|
386
|
+
const what =
|
|
387
|
+
config.mode === "artifact"
|
|
388
|
+
? "Show a whole HTML page you write: a report, a document, a small app."
|
|
389
|
+
: config.mode === "mcp-app"
|
|
390
|
+
? "Show an MCP Apps view."
|
|
391
|
+
: "Render an SVG or an HTML fragment you write, shown inline: charts, diagrams, explainers, small interactive tools.";
|
|
392
|
+
const origins = [
|
|
393
|
+
...new Set(
|
|
394
|
+
(["script", "style", "font", "img", "media", "connect"] as const).flatMap((kind) =>
|
|
395
|
+
egressOrigins(config, kind),
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
];
|
|
399
|
+
const net =
|
|
400
|
+
origins.length === 0
|
|
401
|
+
? "with no network (nothing loads from outside)"
|
|
402
|
+
: `loading only from ${origins.join(", ")}`;
|
|
403
|
+
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
404
|
+
const spec = capabilitySpec(name);
|
|
405
|
+
if (spec.global === undefined || !c.global(spec.global)) {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
if (spec.global === "sendPrompt") {
|
|
409
|
+
return ["sendPrompt(text) asks the person to send text to you as their message."];
|
|
410
|
+
}
|
|
411
|
+
if (spec.global === "openLink") {
|
|
412
|
+
return ["openLink(url) asks the person to open a link."];
|
|
413
|
+
}
|
|
414
|
+
return [];
|
|
415
|
+
});
|
|
416
|
+
const read =
|
|
417
|
+
config.guide.guideToolName === undefined
|
|
418
|
+
? ""
|
|
419
|
+
: ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
|
|
420
|
+
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(
|
|
421
|
+
/ {2,}/gu,
|
|
422
|
+
" ",
|
|
423
|
+
);
|
|
424
|
+
}
|