@otto-code/protocol 0.6.7 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-labels.d.ts +21 -0
- package/dist/agent-labels.js +66 -0
- package/dist/agent-types.d.ts +55 -0
- package/dist/agent-types.js +14 -0
- package/dist/generated/validation/ws-outbound.aot.js +38262 -31722
- package/dist/git-hosting.d.ts +2 -0
- package/dist/git-hosting.js +6 -0
- package/dist/messages.d.ts +7196 -430
- package/dist/messages.js +1872 -37
- package/dist/model-tiers.js +2 -0
- package/dist/orchestration.d.ts +172 -0
- package/dist/orchestration.js +226 -0
- package/dist/provider-config.d.ts +3 -1
- package/dist/provider-config.js +3 -0
- package/dist/validation/ws-outbound-schema-metadata.d.ts +1085 -15
- package/dist/widgets/bridge.d.ts +59 -0
- package/dist/widgets/bridge.js +87 -0
- package/dist/widgets/document.d.ts +18 -0
- package/dist/widgets/document.js +261 -0
- package/dist/widgets/icons.d.ts +20 -0
- package/dist/widgets/icons.js +90 -0
- package/dist/widgets/theme.d.ts +37 -0
- package/dist/widgets/theme.js +149 -0
- package/dist/widgets/types.d.ts +78 -0
- package/dist/widgets/types.js +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The widget message bridge — the contract between a widget guest (WebView /
|
|
4
|
+
* iframe / Electron <webview>) and its host renderer.
|
|
5
|
+
*
|
|
6
|
+
* Three transports carry the same two message shapes:
|
|
7
|
+
* native — `window.ReactNativeWebView.postMessage` + `onMessage`
|
|
8
|
+
* web — a `MessageChannel` port transferred in at load
|
|
9
|
+
* electron — a preload's `ipcRenderer.sendToHost` + the `ipc-message` event
|
|
10
|
+
*
|
|
11
|
+
* The schema lives here, once, so a transport cannot quietly drift. Everything
|
|
12
|
+
* arriving from a guest is untrusted model-generated content: parse, never
|
|
13
|
+
* trust, and drop anything that does not match.
|
|
14
|
+
*/
|
|
15
|
+
/** Marker on every guest→host frame, so foreign postMessage traffic is ignored. */
|
|
16
|
+
export declare const WIDGET_BRIDGE_CHANNEL = "otto.widget.v1";
|
|
17
|
+
/**
|
|
18
|
+
* `sendPrompt` types into the user's chat. That is a privilege, so it is
|
|
19
|
+
* capped: a widget cannot paste an essay, and cannot machine-gun the composer.
|
|
20
|
+
*/
|
|
21
|
+
export declare const WIDGET_PROMPT_MAX_CHARS = 2000;
|
|
22
|
+
export declare const WIDGET_PROMPT_MIN_INTERVAL_MS = 1000;
|
|
23
|
+
/** Total prompts one widget may send in a session, however slowly. */
|
|
24
|
+
export declare const WIDGET_PROMPT_SESSION_LIMIT = 20;
|
|
25
|
+
/** Clamp for self-reported guest height, in CSS pixels. */
|
|
26
|
+
export declare const WIDGET_MIN_HEIGHT_PX = 24;
|
|
27
|
+
export declare const WIDGET_MAX_HEIGHT_PX = 4000;
|
|
28
|
+
export declare const WidgetGuestMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
29
|
+
channel: z.ZodLiteral<"otto.widget.v1">;
|
|
30
|
+
widgetId: z.ZodString;
|
|
31
|
+
type: z.ZodLiteral<"height">;
|
|
32
|
+
px: z.ZodNumber;
|
|
33
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
34
|
+
channel: z.ZodLiteral<"otto.widget.v1">;
|
|
35
|
+
widgetId: z.ZodString;
|
|
36
|
+
type: z.ZodLiteral<"prompt">;
|
|
37
|
+
text: z.ZodString;
|
|
38
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
39
|
+
channel: z.ZodLiteral<"otto.widget.v1">;
|
|
40
|
+
widgetId: z.ZodString;
|
|
41
|
+
type: z.ZodLiteral<"open_link">;
|
|
42
|
+
url: z.ZodString;
|
|
43
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
44
|
+
channel: z.ZodLiteral<"otto.widget.v1">;
|
|
45
|
+
widgetId: z.ZodString;
|
|
46
|
+
type: z.ZodLiteral<"error">;
|
|
47
|
+
message: z.ZodString;
|
|
48
|
+
}, z.core.$strip>], "type">;
|
|
49
|
+
export type WidgetGuestMessage = z.infer<typeof WidgetGuestMessageSchema>;
|
|
50
|
+
/**
|
|
51
|
+
* Parse a raw guest frame. Accepts the JSON string the native and Electron
|
|
52
|
+
* transports deliver as well as an already-structured object (web's
|
|
53
|
+
* MessageChannel). Returns null for anything unrecognized — including frames
|
|
54
|
+
* for a different widget, which the caller filters by id.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseWidgetGuestMessage(raw: unknown): WidgetGuestMessage | null;
|
|
57
|
+
/** Clamp a self-reported height into something a chat row can hold. */
|
|
58
|
+
export declare function clampWidgetHeight(px: number): number;
|
|
59
|
+
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The widget message bridge — the contract between a widget guest (WebView /
|
|
4
|
+
* iframe / Electron <webview>) and its host renderer.
|
|
5
|
+
*
|
|
6
|
+
* Three transports carry the same two message shapes:
|
|
7
|
+
* native — `window.ReactNativeWebView.postMessage` + `onMessage`
|
|
8
|
+
* web — a `MessageChannel` port transferred in at load
|
|
9
|
+
* electron — a preload's `ipcRenderer.sendToHost` + the `ipc-message` event
|
|
10
|
+
*
|
|
11
|
+
* The schema lives here, once, so a transport cannot quietly drift. Everything
|
|
12
|
+
* arriving from a guest is untrusted model-generated content: parse, never
|
|
13
|
+
* trust, and drop anything that does not match.
|
|
14
|
+
*/
|
|
15
|
+
/** Marker on every guest→host frame, so foreign postMessage traffic is ignored. */
|
|
16
|
+
export const WIDGET_BRIDGE_CHANNEL = "otto.widget.v1";
|
|
17
|
+
/**
|
|
18
|
+
* `sendPrompt` types into the user's chat. That is a privilege, so it is
|
|
19
|
+
* capped: a widget cannot paste an essay, and cannot machine-gun the composer.
|
|
20
|
+
*/
|
|
21
|
+
export const WIDGET_PROMPT_MAX_CHARS = 2000;
|
|
22
|
+
export const WIDGET_PROMPT_MIN_INTERVAL_MS = 1000;
|
|
23
|
+
/** Total prompts one widget may send in a session, however slowly. */
|
|
24
|
+
export const WIDGET_PROMPT_SESSION_LIMIT = 20;
|
|
25
|
+
/** Clamp for self-reported guest height, in CSS pixels. */
|
|
26
|
+
export const WIDGET_MIN_HEIGHT_PX = 24;
|
|
27
|
+
export const WIDGET_MAX_HEIGHT_PX = 4000;
|
|
28
|
+
const WidgetHeightMessageSchema = z.object({
|
|
29
|
+
channel: z.literal(WIDGET_BRIDGE_CHANNEL),
|
|
30
|
+
widgetId: z.string().min(1),
|
|
31
|
+
type: z.literal("height"),
|
|
32
|
+
/** Content height the guest measured for its in-flow content. */
|
|
33
|
+
px: z.number().finite().nonnegative(),
|
|
34
|
+
});
|
|
35
|
+
const WidgetPromptMessageSchema = z.object({
|
|
36
|
+
channel: z.literal(WIDGET_BRIDGE_CHANNEL),
|
|
37
|
+
widgetId: z.string().min(1),
|
|
38
|
+
type: z.literal("prompt"),
|
|
39
|
+
text: z.string().min(1),
|
|
40
|
+
});
|
|
41
|
+
const WidgetOpenLinkMessageSchema = z.object({
|
|
42
|
+
channel: z.literal(WIDGET_BRIDGE_CHANNEL),
|
|
43
|
+
widgetId: z.string().min(1),
|
|
44
|
+
type: z.literal("open_link"),
|
|
45
|
+
url: z.string().min(1),
|
|
46
|
+
});
|
|
47
|
+
const WidgetErrorMessageSchema = z.object({
|
|
48
|
+
channel: z.literal(WIDGET_BRIDGE_CHANNEL),
|
|
49
|
+
widgetId: z.string().min(1),
|
|
50
|
+
type: z.literal("error"),
|
|
51
|
+
message: z.string(),
|
|
52
|
+
});
|
|
53
|
+
export const WidgetGuestMessageSchema = z.discriminatedUnion("type", [
|
|
54
|
+
WidgetHeightMessageSchema,
|
|
55
|
+
WidgetPromptMessageSchema,
|
|
56
|
+
WidgetOpenLinkMessageSchema,
|
|
57
|
+
WidgetErrorMessageSchema,
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Parse a raw guest frame. Accepts the JSON string the native and Electron
|
|
61
|
+
* transports deliver as well as an already-structured object (web's
|
|
62
|
+
* MessageChannel). Returns null for anything unrecognized — including frames
|
|
63
|
+
* for a different widget, which the caller filters by id.
|
|
64
|
+
*/
|
|
65
|
+
export function parseWidgetGuestMessage(raw) {
|
|
66
|
+
let value = raw;
|
|
67
|
+
if (typeof raw === "string") {
|
|
68
|
+
// A widget's own console noise and any unrelated postMessage traffic land
|
|
69
|
+
// here too; non-JSON is simply not ours.
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const parsed = WidgetGuestMessageSchema.safeParse(value);
|
|
78
|
+
return parsed.success ? parsed.data : null;
|
|
79
|
+
}
|
|
80
|
+
/** Clamp a self-reported height into something a chat row can hold. */
|
|
81
|
+
export function clampWidgetHeight(px) {
|
|
82
|
+
if (!Number.isFinite(px)) {
|
|
83
|
+
return WIDGET_MIN_HEIGHT_PX;
|
|
84
|
+
}
|
|
85
|
+
return Math.min(WIDGET_MAX_HEIGHT_PX, Math.max(WIDGET_MIN_HEIGHT_PX, Math.ceil(px)));
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type WidgetThemeInput } from "./theme.js";
|
|
2
|
+
import type { WidgetPayload } from "./types.js";
|
|
3
|
+
/** Host→guest handshake that hands over the web transport's MessagePort. */
|
|
4
|
+
export declare const WIDGET_PORT_HANDSHAKE = "otto.widget.port.v1";
|
|
5
|
+
export interface BuildWidgetDocumentInput {
|
|
6
|
+
payload: WidgetPayload;
|
|
7
|
+
theme: WidgetThemeInput;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Wrap a sanitized fragment into the full guest document.
|
|
11
|
+
*
|
|
12
|
+
* The fragment is inserted verbatim — it was already sanitized daemon-side
|
|
13
|
+
* (`packages/server/src/server/widget/widget-fragment.ts`) and the CSP plus the
|
|
14
|
+
* per-platform sandbox are what actually contain it. Re-parsing it here would
|
|
15
|
+
* add a second, weaker sanitizer that only ever disagrees with the first.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildWidgetDocument(input: BuildWidgetDocumentInput): string;
|
|
18
|
+
//# sourceMappingURL=document.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { WIDGET_BRIDGE_CHANNEL } from "./bridge.js";
|
|
2
|
+
import { buildWidgetIconRules } from "./icons.js";
|
|
3
|
+
import { buildWidgetThemeVariables } from "./theme.js";
|
|
4
|
+
/**
|
|
5
|
+
* Assembles the document a widget guest actually loads.
|
|
6
|
+
*
|
|
7
|
+
* Shared by all three renderers (native WebView, web iframe, Electron
|
|
8
|
+
* <webview>) so the CSP, the host stylesheet and the bridge bootstrap cannot
|
|
9
|
+
* drift apart per platform — a widget that works in one has to work in all of
|
|
10
|
+
* them.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* No network. At all.
|
|
14
|
+
*
|
|
15
|
+
* Claude's widget host permits five public CDNs plus Google Fonts, which is
|
|
16
|
+
* what makes its `chart` module's Chart.js/D3 vocabulary work. Otto does not
|
|
17
|
+
* follow it, for three reasons that all point the same way:
|
|
18
|
+
*
|
|
19
|
+
* 1. A widget rendered on a phone over the relay cannot reach a daemon-local
|
|
20
|
+
* asset origin, so any "serve it ourselves" scheme degrades in exactly the
|
|
21
|
+
* case Otto exists for.
|
|
22
|
+
* 2. Inlining a library per widget puts 200-400KB into the timeline on every
|
|
23
|
+
* call, re-sent on every backfill.
|
|
24
|
+
* 3. A model-authored fragment may be reflecting content the model read from
|
|
25
|
+
* a hostile file or page, and an outbound URL is a payload.
|
|
26
|
+
*
|
|
27
|
+
* The fragment therefore gets inline script/style and data: assets, and nothing
|
|
28
|
+
* else. Charts are hand-rolled SVG — the contract document says so plainly, and
|
|
29
|
+
* a blocked resource surfaces as a visible error rather than a blank box.
|
|
30
|
+
*/
|
|
31
|
+
const WIDGET_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
|
32
|
+
"img-src data: blob:; font-src data:; connect-src 'none'; frame-src 'none'; " +
|
|
33
|
+
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
34
|
+
/** Host→guest handshake that hands over the web transport's MessagePort. */
|
|
35
|
+
export const WIDGET_PORT_HANDSHAKE = "otto.widget.port.v1";
|
|
36
|
+
const BASE_RULES = `
|
|
37
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
38
|
+
html, body {
|
|
39
|
+
margin: 0;
|
|
40
|
+
padding: 0;
|
|
41
|
+
background: transparent;
|
|
42
|
+
color: var(--text-primary);
|
|
43
|
+
font-family: var(--font-sans);
|
|
44
|
+
font-size: 14px;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
/* The chat scrolls; the widget never does. Height is content-driven and
|
|
47
|
+
reported to the host, so an inner scroller would hide content behind a
|
|
48
|
+
frame that is already exactly as tall as it needs to be. */
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
body { display: block; width: 100%; }
|
|
52
|
+
h1, h2, h3, h4 { margin: 0 0 var(--gap-sm); line-height: 1.25; font-weight: 600; }
|
|
53
|
+
h1 { font-size: 20px; }
|
|
54
|
+
h2 { font-size: 17px; }
|
|
55
|
+
h3 { font-size: 15px; }
|
|
56
|
+
p { margin: 0 0 var(--gap-sm); }
|
|
57
|
+
p:last-child { margin-bottom: 0; }
|
|
58
|
+
code, pre, kbd, samp { font-family: var(--font-mono); font-size: 0.92em; }
|
|
59
|
+
pre { margin: 0; overflow-x: auto; }
|
|
60
|
+
a { color: var(--text-accent); text-decoration: none; cursor: pointer; }
|
|
61
|
+
a:hover { text-decoration: underline; }
|
|
62
|
+
button { font: inherit; color: inherit; cursor: pointer; }
|
|
63
|
+
table { border-collapse: collapse; width: 100%; }
|
|
64
|
+
th, td { text-align: left; padding: var(--pad-sm); border-bottom: 1px solid var(--border); }
|
|
65
|
+
th { color: var(--text-secondary); font-weight: 600; }
|
|
66
|
+
img, svg, canvas, video { max-width: 100%; }
|
|
67
|
+
.sr-only {
|
|
68
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
69
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
70
|
+
}
|
|
71
|
+
/* SVG palette. \`c-\` fills, \`s-\` strokes; \`t\`/\`ts\`/\`th\` are the three text
|
|
72
|
+
weights, matching the HTML \`--text-*\` ladder so a diagram and a paragraph
|
|
73
|
+
agree about emphasis. */
|
|
74
|
+
.t { fill: var(--text-primary); }
|
|
75
|
+
.ts { fill: var(--text-secondary); }
|
|
76
|
+
.th { fill: var(--text-muted); }
|
|
77
|
+
svg text { font-family: var(--font-sans); }
|
|
78
|
+
`;
|
|
79
|
+
function buildPaletteRules() {
|
|
80
|
+
const names = ["blue", "teal", "amber", "red", "green", "purple", "pink", "gray"];
|
|
81
|
+
return names
|
|
82
|
+
.map((name) => `.c-${name}{fill:var(--c-${name});}\n.s-${name}{stroke:var(--c-${name});}`)
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The guest-side bridge.
|
|
87
|
+
*
|
|
88
|
+
* Defines the two host globals the contract promises, reports content height,
|
|
89
|
+
* and routes link clicks. Written as a plain string rather than a bundled
|
|
90
|
+
* module because it has to run inside a `data:`/`srcDoc` document with no
|
|
91
|
+
* loader, and because keeping it readable here is the only way it stays
|
|
92
|
+
* reviewable.
|
|
93
|
+
*/
|
|
94
|
+
function buildBootstrap(widgetId) {
|
|
95
|
+
return `
|
|
96
|
+
(function () {
|
|
97
|
+
var CHANNEL = ${JSON.stringify(WIDGET_BRIDGE_CHANNEL)};
|
|
98
|
+
var WIDGET_ID = ${JSON.stringify(widgetId)};
|
|
99
|
+
var HANDSHAKE = ${JSON.stringify(WIDGET_PORT_HANDSHAKE)};
|
|
100
|
+
var port = null;
|
|
101
|
+
var queued = [];
|
|
102
|
+
|
|
103
|
+
function deliver(payload) {
|
|
104
|
+
var text = JSON.stringify(payload);
|
|
105
|
+
// Electron: a preload on the guest's own session exposes exactly this one
|
|
106
|
+
// function and nothing else (no ipcRenderer, no node).
|
|
107
|
+
if (window.__ottoWidgetHost && typeof window.__ottoWidgetHost.post === "function") {
|
|
108
|
+
window.__ottoWidgetHost.post(text);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// Native: react-native-webview's injected bridge.
|
|
112
|
+
if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === "function") {
|
|
113
|
+
window.ReactNativeWebView.postMessage(text);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// Web: the MessagePort the host transferred in. Validating on port identity
|
|
117
|
+
// is the point — this iframe has no allow-same-origin, so its origin is
|
|
118
|
+
// "null" and event.origin can prove nothing.
|
|
119
|
+
if (port) {
|
|
120
|
+
port.postMessage(payload);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function post(payload) {
|
|
127
|
+
if (!deliver(payload)) {
|
|
128
|
+
if (queued.length < 32) { queued.push(payload); }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
window.addEventListener("message", function (event) {
|
|
133
|
+
if (event.data !== HANDSHAKE || !event.ports || !event.ports[0]) { return; }
|
|
134
|
+
port = event.ports[0];
|
|
135
|
+
var pending = queued;
|
|
136
|
+
queued = [];
|
|
137
|
+
for (var i = 0; i < pending.length; i += 1) { deliver(pending[i]); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
var lastHeight = -1;
|
|
141
|
+
var frame = 0;
|
|
142
|
+
function measure() {
|
|
143
|
+
var doc = document.documentElement;
|
|
144
|
+
var body = document.body;
|
|
145
|
+
// Take the max rather than documentElement alone: a fragment that puts
|
|
146
|
+
// content in an absolutely-positioned or floated box can leave the root's
|
|
147
|
+
// own height behind what is actually painted.
|
|
148
|
+
var height = Math.max(
|
|
149
|
+
doc ? doc.scrollHeight : 0,
|
|
150
|
+
body ? body.scrollHeight : 0,
|
|
151
|
+
body ? body.offsetHeight : 0
|
|
152
|
+
);
|
|
153
|
+
if (height === lastHeight) { return; }
|
|
154
|
+
lastHeight = height;
|
|
155
|
+
post({ channel: CHANNEL, widgetId: WIDGET_ID, type: "height", px: height });
|
|
156
|
+
}
|
|
157
|
+
function scheduleMeasure() {
|
|
158
|
+
if (frame) { return; }
|
|
159
|
+
frame = requestAnimationFrame(function () { frame = 0; measure(); });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof ResizeObserver === "function") {
|
|
163
|
+
var observer = new ResizeObserver(scheduleMeasure);
|
|
164
|
+
observer.observe(document.documentElement);
|
|
165
|
+
if (document.body) { observer.observe(document.body); }
|
|
166
|
+
}
|
|
167
|
+
window.addEventListener("load", scheduleMeasure);
|
|
168
|
+
// Fonts and late layout settle after load; a short poll costs nothing and
|
|
169
|
+
// catches the cases the observer misses (image decode, script-built DOM).
|
|
170
|
+
var polls = 0;
|
|
171
|
+
var poll = setInterval(function () {
|
|
172
|
+
polls += 1;
|
|
173
|
+
measure();
|
|
174
|
+
if (polls > 20) { clearInterval(poll); }
|
|
175
|
+
}, 150);
|
|
176
|
+
scheduleMeasure();
|
|
177
|
+
|
|
178
|
+
window.sendPrompt = function (text) {
|
|
179
|
+
if (typeof text !== "string") { return; }
|
|
180
|
+
var trimmed = text.trim();
|
|
181
|
+
if (!trimmed) { return; }
|
|
182
|
+
post({ channel: CHANNEL, widgetId: WIDGET_ID, type: "prompt", text: trimmed });
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
window.openLink = function (url) {
|
|
186
|
+
if (typeof url !== "string" || !url) { return; }
|
|
187
|
+
post({ channel: CHANNEL, widgetId: WIDGET_ID, type: "open_link", url: url });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Plain <a href> clicks route through the same confirmation path as
|
|
191
|
+
// openLink(). Nothing in a widget navigates the guest itself.
|
|
192
|
+
document.addEventListener("click", function (event) {
|
|
193
|
+
var node = event.target;
|
|
194
|
+
while (node && node !== document.body) {
|
|
195
|
+
if (node.tagName === "A" && node.getAttribute("href")) {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
window.openLink(node.getAttribute("href"));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
node = node.parentNode;
|
|
201
|
+
}
|
|
202
|
+
}, true);
|
|
203
|
+
|
|
204
|
+
window.addEventListener("error", function (event) {
|
|
205
|
+
post({
|
|
206
|
+
channel: CHANNEL,
|
|
207
|
+
widgetId: WIDGET_ID,
|
|
208
|
+
type: "error",
|
|
209
|
+
message: String((event && event.message) || "Widget script error")
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
})();
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Wrap a sanitized fragment into the full guest document.
|
|
217
|
+
*
|
|
218
|
+
* The fragment is inserted verbatim — it was already sanitized daemon-side
|
|
219
|
+
* (`packages/server/src/server/widget/widget-fragment.ts`) and the CSP plus the
|
|
220
|
+
* per-platform sandbox are what actually contain it. Re-parsing it here would
|
|
221
|
+
* add a second, weaker sanitizer that only ever disagrees with the first.
|
|
222
|
+
*/
|
|
223
|
+
export function buildWidgetDocument(input) {
|
|
224
|
+
const { payload, theme } = input;
|
|
225
|
+
const styles = [
|
|
226
|
+
buildWidgetThemeVariables(theme),
|
|
227
|
+
BASE_RULES,
|
|
228
|
+
buildPaletteRules(),
|
|
229
|
+
buildWidgetIconRules(),
|
|
230
|
+
].join("\n");
|
|
231
|
+
// SVG-mode fragments are centered in the frame; an SVG sized to its viewBox
|
|
232
|
+
// otherwise pins left and reads as a mistake at wide chat widths.
|
|
233
|
+
const bodyAttrs = payload.mode === "svg" ? ' class="widget-svg"' : "";
|
|
234
|
+
const modeRules = payload.mode === "svg"
|
|
235
|
+
? "body.widget-svg{display:flex;justify-content:center;}body.widget-svg>svg{height:auto;}"
|
|
236
|
+
: "";
|
|
237
|
+
return [
|
|
238
|
+
"<!DOCTYPE html>",
|
|
239
|
+
'<html lang="en">',
|
|
240
|
+
"<head>",
|
|
241
|
+
'<meta charset="utf-8">',
|
|
242
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
243
|
+
`<meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">`,
|
|
244
|
+
`<title>${escapeHtml(payload.title)}</title>`,
|
|
245
|
+
`<style>${styles}\n${modeRules}</style>`,
|
|
246
|
+
"</head>",
|
|
247
|
+
`<body${bodyAttrs}>`,
|
|
248
|
+
payload.code,
|
|
249
|
+
`<script>${buildBootstrap(payload.id)}</script>`,
|
|
250
|
+
"</body>",
|
|
251
|
+
"</html>",
|
|
252
|
+
].join("\n");
|
|
253
|
+
}
|
|
254
|
+
function escapeHtml(value) {
|
|
255
|
+
return value
|
|
256
|
+
.replace(/&/g, "&")
|
|
257
|
+
.replace(/</g, "<")
|
|
258
|
+
.replace(/>/g, ">")
|
|
259
|
+
.replace(/"/g, """);
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=document.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A curated icon set for widgets, used as `<i class="ti ti-check"></i>`.
|
|
3
|
+
*
|
|
4
|
+
* Claude's widget host describes a 5800-glyph Tabler outline webfont as
|
|
5
|
+
* "already loaded". Otto does not load it: the widget CSP is `default-src
|
|
6
|
+
* 'none'` with no network at all (see `document.ts`), and a webfont large
|
|
7
|
+
* enough to cover 5800 glyphs would have to be either inlined into every guest
|
|
8
|
+
* document or served from an origin that does not survive relay/remote access.
|
|
9
|
+
*
|
|
10
|
+
* So the set is curated instead: ~40 glyphs covering what widgets actually
|
|
11
|
+
* reach for, drawn in Tabler's outline language (24px box, 2px stroke, round
|
|
12
|
+
* caps) and applied as a CSS mask so each one inherits `currentColor`. A name
|
|
13
|
+
* outside the set renders nothing rather than a broken box — and the contract
|
|
14
|
+
* document lists exactly what exists, so the model never guesses.
|
|
15
|
+
*/
|
|
16
|
+
/** Every icon name a widget may use. The contract document renders this list. */
|
|
17
|
+
export declare const WIDGET_ICON_NAMES: readonly string[];
|
|
18
|
+
/** The `.ti-*` rules. Emitted once into every guest document. */
|
|
19
|
+
export declare function buildWidgetIconRules(): string;
|
|
20
|
+
//# sourceMappingURL=icons.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A curated icon set for widgets, used as `<i class="ti ti-check"></i>`.
|
|
3
|
+
*
|
|
4
|
+
* Claude's widget host describes a 5800-glyph Tabler outline webfont as
|
|
5
|
+
* "already loaded". Otto does not load it: the widget CSP is `default-src
|
|
6
|
+
* 'none'` with no network at all (see `document.ts`), and a webfont large
|
|
7
|
+
* enough to cover 5800 glyphs would have to be either inlined into every guest
|
|
8
|
+
* document or served from an origin that does not survive relay/remote access.
|
|
9
|
+
*
|
|
10
|
+
* So the set is curated instead: ~40 glyphs covering what widgets actually
|
|
11
|
+
* reach for, drawn in Tabler's outline language (24px box, 2px stroke, round
|
|
12
|
+
* caps) and applied as a CSS mask so each one inherits `currentColor`. A name
|
|
13
|
+
* outside the set renders nothing rather than a broken box — and the contract
|
|
14
|
+
* document lists exactly what exists, so the model never guesses.
|
|
15
|
+
*/
|
|
16
|
+
const ICON_PATHS = {
|
|
17
|
+
check: '<path d="M5 12l5 5L20 7"/>',
|
|
18
|
+
x: '<path d="M18 6L6 18M6 6l12 12"/>',
|
|
19
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
20
|
+
minus: '<path d="M5 12h14"/>',
|
|
21
|
+
"chevron-right": '<path d="M9 6l6 6-6 6"/>',
|
|
22
|
+
"chevron-left": '<path d="M15 6l-6 6 6 6"/>',
|
|
23
|
+
"chevron-down": '<path d="M6 9l6 6 6-6"/>',
|
|
24
|
+
"chevron-up": '<path d="M6 15l6-6 6 6"/>',
|
|
25
|
+
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
|
26
|
+
"arrow-left": '<path d="M19 12H5M11 18l-6-6 6-6"/>',
|
|
27
|
+
"arrow-up": '<path d="M12 19V5M6 11l6-6 6 6"/>',
|
|
28
|
+
"arrow-down": '<path d="M12 5v14M18 13l-6 6-6-6"/>',
|
|
29
|
+
"trending-up": '<path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/>',
|
|
30
|
+
"trending-down": '<path d="M3 7l6 6 4-4 8 8"/><path d="M14 17h7v-7"/>',
|
|
31
|
+
"circle-check": '<circle cx="12" cy="12" r="9"/><path d="M9 12l2 2 4-4"/>',
|
|
32
|
+
"circle-x": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
|
33
|
+
"alert-triangle": '<path d="M10.3 4.3L2.8 17a2 2 0 001.7 3h15a2 2 0 001.7-3L13.7 4.3a2 2 0 00-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
|
|
34
|
+
"alert-circle": '<circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/>',
|
|
35
|
+
"info-circle": '<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>',
|
|
36
|
+
"help-circle": '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 015.8 1c0 2-3 3-3 3M12 17h.01"/>',
|
|
37
|
+
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="M21 21l-5.9-5.9"/>',
|
|
38
|
+
home: '<path d="M5 12L3 12l9-9 9 9h-2"/><path d="M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"/>',
|
|
39
|
+
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.6 1.6 0 00-1.8-.3 1.6 1.6 0 00-1 1.5V21a2 2 0 11-4 0v-.1A1.6 1.6 0 008 19.4a1.6 1.6 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.6 1.6 0 00.3-1.8 1.6 1.6 0 00-1.5-1H2a2 2 0 110-4h.1A1.6 1.6 0 004.6 8a1.6 1.6 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.6 1.6 0 001.8.3H9a1.6 1.6 0 001-1.5V2a2 2 0 114 0v.1a1.6 1.6 0 001 1.5 1.6 1.6 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.6 1.6 0 00-.3 1.8V9a1.6 1.6 0 001.5 1H22a2 2 0 110 4h-.1a1.6 1.6 0 00-1.5 1z"/>',
|
|
40
|
+
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 016-6h4a6 6 0 016 6v1"/>',
|
|
41
|
+
users: '<circle cx="9" cy="8" r="4"/><path d="M2 21v-1a6 6 0 016-6h2a6 6 0 016 6v1"/><path d="M17 4.5a4 4 0 010 7M19 14a5 5 0 013 4.5V20"/>',
|
|
42
|
+
file: '<path d="M14 3v5h5"/><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8l-5-5z"/>',
|
|
43
|
+
folder: '<path d="M4 5a2 2 0 012-2h3l2 3h7a2 2 0 012 2v9a2 2 0 01-2 2H6a2 2 0 01-2-2z"/>',
|
|
44
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
45
|
+
calendar: '<rect x="4" y="5" width="16" height="16" rx="2"/><path d="M16 3v4M8 3v4M4 11h16"/>',
|
|
46
|
+
star: '<path d="M12 4l2.5 5.1 5.6.8-4 3.9 1 5.6-5.1-2.7-5 2.7 1-5.6-4-3.9 5.5-.8z"/>',
|
|
47
|
+
heart: '<path d="M12 20l-7-7a4.2 4.2 0 010-6 4.2 4.2 0 016 0l1 1 1-1a4.2 4.2 0 016 0 4.2 4.2 0 010 6z"/>',
|
|
48
|
+
trash: '<path d="M4 7h16M10 11v6M14 11v6"/><path d="M5 7l1 12a2 2 0 002 2h8a2 2 0 002-2l1-12M9 7V4h6v3"/>',
|
|
49
|
+
pencil: '<path d="M4 20h4L20 8a2.8 2.8 0 00-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
|
|
50
|
+
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
|
51
|
+
download: '<path d="M12 3v12M7 11l5 5 5-5"/><path d="M4 20h16"/>',
|
|
52
|
+
upload: '<path d="M12 20V8M7 12l5-5 5 5"/><path d="M4 4h16"/>',
|
|
53
|
+
"external-link": '<path d="M14 4h6v6"/><path d="M20 4L10 14"/><path d="M19 14v5a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h5"/>',
|
|
54
|
+
refresh: '<path d="M20 11a8 8 0 10-2 6"/><path d="M20 5v6h-6"/>',
|
|
55
|
+
bolt: '<path d="M13 3L5 14h6l-1 7 8-11h-6z"/>',
|
|
56
|
+
database: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
|
57
|
+
code: '<path d="M9 8l-5 4 5 4M15 8l5 4-5 4"/>',
|
|
58
|
+
terminal: '<path d="M5 7l5 5-5 5M13 17h6"/>',
|
|
59
|
+
"chart-bar": '<path d="M4 20h16"/><rect x="6" y="10" width="3" height="7"/><rect x="11" y="6" width="3" height="11"/><rect x="16" y="13" width="3" height="4"/>',
|
|
60
|
+
"chart-line": '<path d="M4 20h16"/><path d="M5 15l4-5 4 3 5-7"/>',
|
|
61
|
+
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 8l9 6 9-6"/>',
|
|
62
|
+
lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/>',
|
|
63
|
+
eye: '<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12z"/><circle cx="12" cy="12" r="2.5"/>',
|
|
64
|
+
"eye-off": '<path d="M3 3l18 18"/><path d="M10.6 6.2A9.9 9.9 0 0112 6c6.4 0 10 6 10 6a17 17 0 01-3.3 3.9M6.6 8.1A17 17 0 002 12s3.6 6 10 6a9.6 9.6 0 003.4-.6"/>',
|
|
65
|
+
world: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 010 18 15 15 0 010-18z"/>',
|
|
66
|
+
sparkles: '<path d="M9 4l1.4 3.6L14 9l-3.6 1.4L9 14l-1.4-3.6L4 9l3.6-1.4z"/><path d="M17 13l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
|
|
67
|
+
};
|
|
68
|
+
function toMaskUrl(body) {
|
|
69
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
|
70
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
|
|
71
|
+
`stroke-linejoin="round">${body}</svg>`;
|
|
72
|
+
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
73
|
+
}
|
|
74
|
+
/** Every icon name a widget may use. The contract document renders this list. */
|
|
75
|
+
export const WIDGET_ICON_NAMES = Object.keys(ICON_PATHS).sort();
|
|
76
|
+
/** The `.ti-*` rules. Emitted once into every guest document. */
|
|
77
|
+
export function buildWidgetIconRules() {
|
|
78
|
+
const rules = Object.entries(ICON_PATHS).map(([name, body]) => {
|
|
79
|
+
const url = toMaskUrl(body);
|
|
80
|
+
return `.ti-${name}{-webkit-mask-image:${url};mask-image:${url};}`;
|
|
81
|
+
});
|
|
82
|
+
return [
|
|
83
|
+
".ti{display:inline-block;width:1.15em;height:1.15em;vertical-align:-0.18em;",
|
|
84
|
+
"background-color:currentColor;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;",
|
|
85
|
+
"-webkit-mask-position:center;mask-position:center;-webkit-mask-size:contain;",
|
|
86
|
+
"mask-size:contain;}",
|
|
87
|
+
...rules,
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=icons.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSS custom properties a widget fragment is written against, and the
|
|
3
|
+
* mapping from Otto's semantic theme tokens onto them.
|
|
4
|
+
*
|
|
5
|
+
* The HOST assembles the guest document, not the daemon — because only the
|
|
6
|
+
* client knows which theme is live, and a theme switch has to re-skin an
|
|
7
|
+
* already-rendered widget without a daemon round trip. The variable NAMES are
|
|
8
|
+
* frozen here so the contract document, the fixtures, and every renderer agree.
|
|
9
|
+
*/
|
|
10
|
+
export interface WidgetThemeInput {
|
|
11
|
+
/** True when the live Otto theme is a dark one. Drives the tint direction. */
|
|
12
|
+
isDark: boolean;
|
|
13
|
+
surface0: string;
|
|
14
|
+
surface1: string;
|
|
15
|
+
surface2: string;
|
|
16
|
+
/** Highest-elevation surface; also the source of the stronger border steps. */
|
|
17
|
+
surface3: string;
|
|
18
|
+
foreground: string;
|
|
19
|
+
foregroundMuted: string;
|
|
20
|
+
border: string;
|
|
21
|
+
accent: string;
|
|
22
|
+
danger: string;
|
|
23
|
+
success: string;
|
|
24
|
+
/** Optional — themes without a warning token fall back to a fixed amber. */
|
|
25
|
+
warning?: string;
|
|
26
|
+
fontSans: string;
|
|
27
|
+
fontMono: string;
|
|
28
|
+
/** Serif "voice" face. Falls back to a generic serif stack. */
|
|
29
|
+
fontSerif?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function widgetPaletteFor(isDark: boolean): Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* The `:root` custom-property block. Split out from the stylesheet so a theme
|
|
34
|
+
* change can be reasoned about (and tested) without the 200 lines of rules.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildWidgetThemeVariables(input: WidgetThemeInput): string;
|
|
37
|
+
//# sourceMappingURL=theme.d.ts.map
|