@womp/mcp-ui 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 +21 -0
- package/README.md +216 -0
- package/dist/image/index.html +259 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/mesh/index.html +4378 -0
- package/package.json +42 -0
- package/src/image/index.html +185 -0
- package/src/image/main.ts +76 -0
- package/src/mesh/index.html +197 -0
- package/src/mesh/main.ts +186 -0
- package/src/shared/bridge.ts +165 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@womp/mcp-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**",
|
|
11
|
+
"src/**"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "yarn build:ui && yarn build:bake",
|
|
18
|
+
"build:ui": "yarn build:ui:image && yarn build:ui:mesh",
|
|
19
|
+
"build:bake": "node scripts/bake-html.mjs",
|
|
20
|
+
"dev": "vite",
|
|
21
|
+
"dev:mcp": "node dev/server.mjs",
|
|
22
|
+
"lint": "tsc --noEmit",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"prepublishOnly": "yarn clean && yarn build",
|
|
25
|
+
"build:ui:image": "CARD=image vite build",
|
|
26
|
+
"build:ui:mesh": "CARD=mesh vite build"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
31
|
+
"@types/three": "^0.185.4",
|
|
32
|
+
"express": "^5.2.1",
|
|
33
|
+
"three": "^0.185.1",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vite": "^8.2.2",
|
|
36
|
+
"vite-plugin-singlefile": "^2.3.3",
|
|
37
|
+
"zod": "^3.25.76"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Womp generation</title>
|
|
6
|
+
<style>
|
|
7
|
+
/* Every var() carries a fallback on purpose: host style variables arrive
|
|
8
|
+
over the bridge (onhostcontextchanged), NOT as CSS custom properties on
|
|
9
|
+
this document, so there is nothing to read until the handshake lands.
|
|
10
|
+
The card has to look right in that window too.
|
|
11
|
+
|
|
12
|
+
Names come from McpUiStyleVariableKeySchema — that is the whole set a
|
|
13
|
+
host may send, so anything outside it will never resolve. */
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: light dark;
|
|
16
|
+
}
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
20
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.card {
|
|
24
|
+
position: relative;
|
|
25
|
+
/* The host hands us the full message width, which turns a generated
|
|
26
|
+
image into a wall. swan-frontend's generation tile is compact and
|
|
27
|
+
left-aligned; this matches it. The iframe stays full width — only the
|
|
28
|
+
card is capped — and auto-resize reports the shorter height, so the
|
|
29
|
+
surrounding chat closes up too. */
|
|
30
|
+
max-width: 360px;
|
|
31
|
+
border-radius: var(--border-radius-lg, 14px);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
background: var(--color-background-secondary, #1b1b1d);
|
|
34
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.08));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.hero {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: auto;
|
|
41
|
+
/* Guard against a tall portrait generation dominating the transcript.
|
|
42
|
+
Cropping is acceptable here because Expand and Open full size both
|
|
43
|
+
reach the uncropped original. */
|
|
44
|
+
max-height: 360px;
|
|
45
|
+
object-fit: cover;
|
|
46
|
+
}
|
|
47
|
+
.skeleton {
|
|
48
|
+
height: 240px;
|
|
49
|
+
background: var(--color-background-tertiary, #2a2a2e);
|
|
50
|
+
}
|
|
51
|
+
/* Only animates while a generation is in flight, so a settled card does
|
|
52
|
+
not look busy. */
|
|
53
|
+
.skeleton.pulsing {
|
|
54
|
+
background: linear-gradient(90deg, #2a2a2e, #37373d, #2a2a2e);
|
|
55
|
+
background-size: 200% 100%;
|
|
56
|
+
animation: sweep 1.4s ease-in-out infinite;
|
|
57
|
+
}
|
|
58
|
+
@keyframes sweep {
|
|
59
|
+
0% { background-position: 200% 0; }
|
|
60
|
+
100% { background-position: -200% 0; }
|
|
61
|
+
}
|
|
62
|
+
@media (prefers-reduced-motion: reduce) {
|
|
63
|
+
.skeleton.pulsing { animation: none; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Overlays float above the image, mirroring swan-frontend's generation
|
|
67
|
+
tile rather than sitting in a bar beneath it. */
|
|
68
|
+
.chips {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: 10px;
|
|
71
|
+
left: 10px;
|
|
72
|
+
display: flex;
|
|
73
|
+
gap: 6px;
|
|
74
|
+
}
|
|
75
|
+
.chip {
|
|
76
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
77
|
+
padding: 3px 8px;
|
|
78
|
+
border-radius: var(--border-radius-full, 999px);
|
|
79
|
+
background: rgba(0, 0, 0, 0.55);
|
|
80
|
+
color: #fff;
|
|
81
|
+
backdrop-filter: blur(6px);
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.actions {
|
|
86
|
+
position: absolute;
|
|
87
|
+
bottom: 10px;
|
|
88
|
+
left: 50%;
|
|
89
|
+
transform: translateX(-50%);
|
|
90
|
+
display: flex;
|
|
91
|
+
gap: 2px;
|
|
92
|
+
padding: 3px;
|
|
93
|
+
border-radius: var(--border-radius-full, 999px);
|
|
94
|
+
background: rgba(0, 0, 0, 0.55);
|
|
95
|
+
backdrop-filter: blur(8px);
|
|
96
|
+
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.3));
|
|
97
|
+
/* Revealed on hover on pointer devices; always visible on touch, where
|
|
98
|
+
there is no hover to reveal them with. */
|
|
99
|
+
opacity: 0;
|
|
100
|
+
transition: opacity 120ms ease;
|
|
101
|
+
}
|
|
102
|
+
.card:hover .actions,
|
|
103
|
+
.card:focus-within .actions {
|
|
104
|
+
opacity: 1;
|
|
105
|
+
}
|
|
106
|
+
@media (hover: none) {
|
|
107
|
+
.actions { opacity: 1; }
|
|
108
|
+
}
|
|
109
|
+
.actions[hidden] { display: none; }
|
|
110
|
+
|
|
111
|
+
.btn {
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 6px;
|
|
115
|
+
border: 0;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
color: #fff;
|
|
118
|
+
background: transparent;
|
|
119
|
+
font: inherit;
|
|
120
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
121
|
+
padding: 6px 10px;
|
|
122
|
+
border-radius: var(--border-radius-full, 999px);
|
|
123
|
+
}
|
|
124
|
+
.btn:hover { background: rgba(255, 255, 255, 0.14); }
|
|
125
|
+
.btn:focus-visible { outline: 2px solid var(--color-ring-primary, #7aa2ff); outline-offset: 1px; }
|
|
126
|
+
.btn svg { width: 14px; height: 14px; display: block; }
|
|
127
|
+
.btn[hidden] { display: none; }
|
|
128
|
+
|
|
129
|
+
/* Fullscreen: the host has given us the whole viewport, so drop the
|
|
130
|
+
inline width cap and let the image use it. Without this the card stays
|
|
131
|
+
a 360px thumbnail in the corner and Expand looks broken. */
|
|
132
|
+
:root[data-display-mode="fullscreen"] body {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
min-height: 100vh;
|
|
137
|
+
}
|
|
138
|
+
:root[data-display-mode="fullscreen"] .card {
|
|
139
|
+
max-width: min(96vw, 1100px);
|
|
140
|
+
border: 0;
|
|
141
|
+
background: transparent;
|
|
142
|
+
}
|
|
143
|
+
:root[data-display-mode="fullscreen"] .hero {
|
|
144
|
+
max-height: 88vh;
|
|
145
|
+
object-fit: contain;
|
|
146
|
+
}
|
|
147
|
+
/* Nothing to reveal on hover when the image fills the screen. */
|
|
148
|
+
:root[data-display-mode="fullscreen"] .actions { opacity: 1; }
|
|
149
|
+
|
|
150
|
+
.status:empty { display: none; }
|
|
151
|
+
.status {
|
|
152
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
153
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
154
|
+
padding: 8px 12px;
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<div class="card" id="card">
|
|
160
|
+
<img id="hero" class="hero" alt="" style="display: none" />
|
|
161
|
+
<div id="skeleton" class="skeleton"></div>
|
|
162
|
+
|
|
163
|
+
<div class="chips">
|
|
164
|
+
<span class="chip" id="kind">Image</span>
|
|
165
|
+
<span class="chip" id="gid" hidden></span>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div class="actions" id="actions" hidden>
|
|
169
|
+
<button class="btn" id="download" title="Open full size" hidden>
|
|
170
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
171
|
+
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 21h16" />
|
|
172
|
+
</svg>
|
|
173
|
+
</button>
|
|
174
|
+
<button class="btn" id="expand" title="Expand" hidden>
|
|
175
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
176
|
+
<path d="M4 10V4h6M20 14v6h-6M20 10V4h-6M4 14v6h6" />
|
|
177
|
+
</svg>
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="status" id="status">Connecting…</div>
|
|
182
|
+
</div>
|
|
183
|
+
<script type="module" src="./main.ts"></script>
|
|
184
|
+
</body>
|
|
185
|
+
</html>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The image card — the light one.
|
|
5
|
+
*
|
|
6
|
+
* Kept deliberately free of three.js: `generate_image` is by far the most
|
|
7
|
+
* common call, and this HTML crosses the wire on every `resources/read`. The
|
|
8
|
+
* mesh viewer is a separate `ui://` resource for exactly that reason.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const setStatus = (text: string) => {
|
|
12
|
+
$("status").textContent = text;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function toggle(id: string, on: boolean, title?: string) {
|
|
16
|
+
const el = $(id);
|
|
17
|
+
el.hidden = !on;
|
|
18
|
+
if (title) el.title = title;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const card = createCard("Womp image", {
|
|
22
|
+
onTimeout: () => setStatus("Still running — ask me to check on it."),
|
|
23
|
+
onResult: (data: GenerationResult) => {
|
|
24
|
+
if (data.status === "running") {
|
|
25
|
+
$("kind").textContent = "Working";
|
|
26
|
+
$("skeleton").classList.add("pulsing");
|
|
27
|
+
$("actions").hidden = true;
|
|
28
|
+
setStatus("Generating…");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
$("skeleton").classList.remove("pulsing");
|
|
33
|
+
|
|
34
|
+
const src = data.previewUrl || data.imageUrl;
|
|
35
|
+
if (src) {
|
|
36
|
+
const img = $("hero") as HTMLImageElement;
|
|
37
|
+
img.onload = () => {
|
|
38
|
+
$("skeleton").style.display = "none";
|
|
39
|
+
img.style.display = "block";
|
|
40
|
+
};
|
|
41
|
+
// A mesh preview is a separate GPU job that can fail permanently with no
|
|
42
|
+
// backfill, so a missing image is expected, not an error.
|
|
43
|
+
img.onerror = () => setStatus("Preview unavailable — the file link still works.");
|
|
44
|
+
img.src = src;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isMesh = !!data.glbUrl;
|
|
48
|
+
$("kind").textContent = isMesh ? "3D mesh" : "Image";
|
|
49
|
+
if (data.generationId) {
|
|
50
|
+
$("gid").textContent = "#" + data.generationId;
|
|
51
|
+
$("gid").hidden = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// No "Make 3D" button on purpose. The card could send a user turn via
|
|
55
|
+
// app.sendMessage(), but the host shows a prompt-injection warning on any
|
|
56
|
+
// widget-composed message — correctly, since a widget is untrusted content
|
|
57
|
+
// — and a red caution banner on a product button is not shippable. The
|
|
58
|
+
// conversion is offered by the model in prose instead, nudged from the
|
|
59
|
+
// tool's response text.
|
|
60
|
+
const fileUrl = data.glbUrl || data.imageUrl;
|
|
61
|
+
toggle("download", !!fileUrl, isMesh ? "Download GLB" : "Open full size");
|
|
62
|
+
toggle("expand", !!src);
|
|
63
|
+
$("actions").hidden = !fileUrl && !src;
|
|
64
|
+
|
|
65
|
+
setStatus("");
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
$("download").addEventListener("click", () => {
|
|
70
|
+
const url = card.current()?.glbUrl || card.current()?.imageUrl;
|
|
71
|
+
if (url) openLink(card.app, url).catch(() => setStatus("Could not open the link."));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$("expand").addEventListener("click", () => {
|
|
75
|
+
toggleFullscreen(card.app).catch(() => setStatus("This host does not support fullscreen."));
|
|
76
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Womp 3D mesh</title>
|
|
6
|
+
<style>
|
|
7
|
+
/* Every var() carries a fallback on purpose: host style variables arrive
|
|
8
|
+
over the bridge (onhostcontextchanged), NOT as CSS custom properties on
|
|
9
|
+
this document, so there is nothing to read until the handshake lands.
|
|
10
|
+
The card has to look right in that window too.
|
|
11
|
+
|
|
12
|
+
Names come from McpUiStyleVariableKeySchema — that is the whole set a
|
|
13
|
+
host may send, so anything outside it will never resolve. */
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: light dark;
|
|
16
|
+
}
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
20
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.card {
|
|
24
|
+
position: relative;
|
|
25
|
+
/* The host hands us the full message width, which turns a generated
|
|
26
|
+
image into a wall. swan-frontend's generation tile is compact and
|
|
27
|
+
left-aligned; this matches it. The iframe stays full width — only the
|
|
28
|
+
card is capped — and auto-resize reports the shorter height, so the
|
|
29
|
+
surrounding chat closes up too. */
|
|
30
|
+
max-width: 360px;
|
|
31
|
+
border-radius: var(--border-radius-lg, 14px);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
background: var(--color-background-secondary, #1b1b1d);
|
|
34
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.08));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.hero {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: auto;
|
|
41
|
+
/* Guard against a tall portrait generation dominating the transcript.
|
|
42
|
+
Cropping is acceptable here because Expand and Open full size both
|
|
43
|
+
reach the uncropped original. */
|
|
44
|
+
max-height: 360px;
|
|
45
|
+
object-fit: cover;
|
|
46
|
+
}
|
|
47
|
+
/* The canvas needs an explicit box: a WebGL canvas has no intrinsic size,
|
|
48
|
+
so without this it collapses to zero height and auto-resize reports a
|
|
49
|
+
card with nothing in it. */
|
|
50
|
+
.viewer {
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 320px;
|
|
53
|
+
touch-action: none; /* let OrbitControls have the gestures */
|
|
54
|
+
}
|
|
55
|
+
.viewer canvas { display: block; width: 100%; height: 100%; }
|
|
56
|
+
:root[data-display-mode="fullscreen"] .viewer { height: 82vh; }
|
|
57
|
+
.viewer[hidden] { display: none; }
|
|
58
|
+
.skeleton {
|
|
59
|
+
height: 320px;
|
|
60
|
+
background: var(--color-background-tertiary, #2a2a2e);
|
|
61
|
+
}
|
|
62
|
+
/* Only animates while a generation is in flight, so a settled card does
|
|
63
|
+
not look busy. */
|
|
64
|
+
.skeleton.pulsing {
|
|
65
|
+
background: linear-gradient(90deg, #2a2a2e, #37373d, #2a2a2e);
|
|
66
|
+
background-size: 200% 100%;
|
|
67
|
+
animation: sweep 1.4s ease-in-out infinite;
|
|
68
|
+
}
|
|
69
|
+
@keyframes sweep {
|
|
70
|
+
0% { background-position: 200% 0; }
|
|
71
|
+
100% { background-position: -200% 0; }
|
|
72
|
+
}
|
|
73
|
+
@media (prefers-reduced-motion: reduce) {
|
|
74
|
+
.skeleton.pulsing { animation: none; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Overlays float above the image, mirroring swan-frontend's generation
|
|
78
|
+
tile rather than sitting in a bar beneath it. */
|
|
79
|
+
.chips {
|
|
80
|
+
position: absolute;
|
|
81
|
+
top: 10px;
|
|
82
|
+
left: 10px;
|
|
83
|
+
display: flex;
|
|
84
|
+
gap: 6px;
|
|
85
|
+
}
|
|
86
|
+
.chip {
|
|
87
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
88
|
+
padding: 3px 8px;
|
|
89
|
+
border-radius: var(--border-radius-full, 999px);
|
|
90
|
+
background: rgba(0, 0, 0, 0.55);
|
|
91
|
+
color: #fff;
|
|
92
|
+
backdrop-filter: blur(6px);
|
|
93
|
+
white-space: nowrap;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.actions {
|
|
97
|
+
position: absolute;
|
|
98
|
+
bottom: 10px;
|
|
99
|
+
left: 50%;
|
|
100
|
+
transform: translateX(-50%);
|
|
101
|
+
display: flex;
|
|
102
|
+
gap: 2px;
|
|
103
|
+
padding: 3px;
|
|
104
|
+
border-radius: var(--border-radius-full, 999px);
|
|
105
|
+
background: rgba(0, 0, 0, 0.55);
|
|
106
|
+
backdrop-filter: blur(8px);
|
|
107
|
+
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.3));
|
|
108
|
+
/* Revealed on hover on pointer devices; always visible on touch, where
|
|
109
|
+
there is no hover to reveal them with. */
|
|
110
|
+
opacity: 0;
|
|
111
|
+
transition: opacity 120ms ease;
|
|
112
|
+
}
|
|
113
|
+
.card:hover .actions,
|
|
114
|
+
.card:focus-within .actions {
|
|
115
|
+
opacity: 1;
|
|
116
|
+
}
|
|
117
|
+
@media (hover: none) {
|
|
118
|
+
.actions { opacity: 1; }
|
|
119
|
+
}
|
|
120
|
+
.actions[hidden] { display: none; }
|
|
121
|
+
|
|
122
|
+
.btn {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 6px;
|
|
126
|
+
border: 0;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
color: #fff;
|
|
129
|
+
background: transparent;
|
|
130
|
+
font: inherit;
|
|
131
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
132
|
+
padding: 6px 10px;
|
|
133
|
+
border-radius: var(--border-radius-full, 999px);
|
|
134
|
+
}
|
|
135
|
+
.btn:hover { background: rgba(255, 255, 255, 0.14); }
|
|
136
|
+
.btn:focus-visible { outline: 2px solid var(--color-ring-primary, #7aa2ff); outline-offset: 1px; }
|
|
137
|
+
.btn svg { width: 14px; height: 14px; display: block; }
|
|
138
|
+
.btn[hidden] { display: none; }
|
|
139
|
+
|
|
140
|
+
/* Fullscreen: the host has given us the whole viewport, so drop the
|
|
141
|
+
inline width cap and let the image use it. Without this the card stays
|
|
142
|
+
a 360px thumbnail in the corner and Expand looks broken. */
|
|
143
|
+
:root[data-display-mode="fullscreen"] body {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
min-height: 100vh;
|
|
148
|
+
}
|
|
149
|
+
:root[data-display-mode="fullscreen"] .card {
|
|
150
|
+
max-width: min(96vw, 1100px);
|
|
151
|
+
border: 0;
|
|
152
|
+
background: transparent;
|
|
153
|
+
}
|
|
154
|
+
:root[data-display-mode="fullscreen"] .hero {
|
|
155
|
+
max-height: 88vh;
|
|
156
|
+
object-fit: contain;
|
|
157
|
+
}
|
|
158
|
+
/* Nothing to reveal on hover when the image fills the screen. */
|
|
159
|
+
:root[data-display-mode="fullscreen"] .actions { opacity: 1; }
|
|
160
|
+
|
|
161
|
+
.status:empty { display: none; }
|
|
162
|
+
.status {
|
|
163
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
164
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
165
|
+
padding: 8px 12px;
|
|
166
|
+
}
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<div class="card" id="card">
|
|
171
|
+
<div id="viewer" class="viewer" hidden></div>
|
|
172
|
+
<img id="hero" class="hero" alt="" style="display: none" />
|
|
173
|
+
<div id="skeleton" class="skeleton"></div>
|
|
174
|
+
|
|
175
|
+
<div class="chips">
|
|
176
|
+
<span class="chip" id="kind">Image</span>
|
|
177
|
+
<span class="chip" id="gid" hidden></span>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="actions" id="actions" hidden>
|
|
181
|
+
<button class="btn" id="download" title="Open full size" hidden>
|
|
182
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
183
|
+
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 21h16" />
|
|
184
|
+
</svg>
|
|
185
|
+
</button>
|
|
186
|
+
<button class="btn" id="expand" title="Expand" hidden>
|
|
187
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
188
|
+
<path d="M4 10V4h6M20 14v6h-6M20 10V4h-6M4 14v6h6" />
|
|
189
|
+
</svg>
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="status" id="status">Connecting…</div>
|
|
194
|
+
</div>
|
|
195
|
+
<script type="module" src="./main.ts"></script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
package/src/mesh/main.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACESFilmicToneMapping,
|
|
3
|
+
AmbientLight,
|
|
4
|
+
Box3,
|
|
5
|
+
Color,
|
|
6
|
+
DirectionalLight,
|
|
7
|
+
PerspectiveCamera,
|
|
8
|
+
Scene,
|
|
9
|
+
SRGBColorSpace,
|
|
10
|
+
Vector3,
|
|
11
|
+
WebGLRenderer,
|
|
12
|
+
} from "three";
|
|
13
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
14
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
15
|
+
|
|
16
|
+
import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The mesh card — an interactive GLB viewer.
|
|
20
|
+
*
|
|
21
|
+
* A separate `ui://` resource from the image card because three.js is ~600 KB
|
|
22
|
+
* and this HTML travels over MCP on every `resources/read`. Bundling it into one
|
|
23
|
+
* shared card would make every `generate_image` call carry a 3D engine it never
|
|
24
|
+
* uses.
|
|
25
|
+
*
|
|
26
|
+
* CSP note: the GLB is fetched with XHR, so the resource must declare
|
|
27
|
+
* `_meta.ui.csp.connectDomains` — `resourceDomains` covers scripts, styles and
|
|
28
|
+
* images only. Getting that wrong looks like a broken viewer rather than a
|
|
29
|
+
* blocked request, which is a slow thing to debug.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const setStatus = (text: string) => {
|
|
33
|
+
$("status").textContent = text;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let renderer: WebGLRenderer | undefined;
|
|
37
|
+
let controls: OrbitControls | undefined;
|
|
38
|
+
let stopLoop = false;
|
|
39
|
+
|
|
40
|
+
/** Fit the camera to the model so any mesh scale frames sensibly. */
|
|
41
|
+
function frame(camera: PerspectiveCamera, target: Vector3, size: number) {
|
|
42
|
+
const distance = size / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
|
43
|
+
camera.position.set(target.x + distance * 0.7, target.y + distance * 0.5, target.z + distance * 0.9);
|
|
44
|
+
camera.near = Math.max(distance / 100, 0.01);
|
|
45
|
+
camera.far = distance * 20;
|
|
46
|
+
camera.updateProjectionMatrix();
|
|
47
|
+
camera.lookAt(target);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function mountViewer(url: string) {
|
|
51
|
+
const canvasHost = $("viewer");
|
|
52
|
+
canvasHost.hidden = false;
|
|
53
|
+
|
|
54
|
+
const scene = new Scene();
|
|
55
|
+
scene.background = null;
|
|
56
|
+
|
|
57
|
+
const camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
|
58
|
+
|
|
59
|
+
renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
|
60
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
61
|
+
renderer.outputColorSpace = SRGBColorSpace;
|
|
62
|
+
// Generated meshes carry PBR materials; without tone mapping they read flat
|
|
63
|
+
// and blown out.
|
|
64
|
+
renderer.toneMapping = ACESFilmicToneMapping;
|
|
65
|
+
canvasHost.appendChild(renderer.domElement);
|
|
66
|
+
|
|
67
|
+
// Cheap three-point-ish lighting rather than an HDR environment map: an
|
|
68
|
+
// environment would mean another cross-origin fetch and another CSP entry.
|
|
69
|
+
scene.add(new AmbientLight(0xffffff, 1.1));
|
|
70
|
+
const key = new DirectionalLight(0xffffff, 2.2);
|
|
71
|
+
key.position.set(3, 5, 4);
|
|
72
|
+
scene.add(key);
|
|
73
|
+
const rim = new DirectionalLight(0x99bbff, 0.8);
|
|
74
|
+
rim.position.set(-4, 2, -3);
|
|
75
|
+
scene.add(rim);
|
|
76
|
+
|
|
77
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
78
|
+
controls.enableDamping = true;
|
|
79
|
+
controls.enablePan = false;
|
|
80
|
+
|
|
81
|
+
const resize = () => {
|
|
82
|
+
const w = canvasHost.clientWidth || 360;
|
|
83
|
+
const h = canvasHost.clientHeight || 360;
|
|
84
|
+
renderer!.setSize(w, h, false);
|
|
85
|
+
camera.aspect = w / h;
|
|
86
|
+
camera.updateProjectionMatrix();
|
|
87
|
+
};
|
|
88
|
+
new ResizeObserver(resize).observe(canvasHost);
|
|
89
|
+
resize();
|
|
90
|
+
|
|
91
|
+
setStatus("Loading model…");
|
|
92
|
+
const gltf = await new GLTFLoader().loadAsync(url, (ev) => {
|
|
93
|
+
if (ev.total) setStatus(`Loading model… ${Math.round((ev.loaded / ev.total) * 100)}%`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
scene.add(gltf.scene);
|
|
97
|
+
|
|
98
|
+
// Recentre on the model's own bounding box: exported meshes are not reliably
|
|
99
|
+
// origin-centred, and an off-centre pivot makes orbiting feel broken.
|
|
100
|
+
const box = new Box3().setFromObject(gltf.scene);
|
|
101
|
+
const centre = box.getCenter(new Vector3());
|
|
102
|
+
const size = box.getSize(new Vector3()).length();
|
|
103
|
+
gltf.scene.position.sub(centre);
|
|
104
|
+
controls.target.set(0, 0, 0);
|
|
105
|
+
frame(camera, new Vector3(0, 0, 0), size);
|
|
106
|
+
controls.update();
|
|
107
|
+
|
|
108
|
+
const loop = () => {
|
|
109
|
+
if (stopLoop) return;
|
|
110
|
+
controls!.update();
|
|
111
|
+
renderer!.render(scene, camera);
|
|
112
|
+
requestAnimationFrame(loop);
|
|
113
|
+
};
|
|
114
|
+
loop();
|
|
115
|
+
|
|
116
|
+
$("skeleton").style.display = "none";
|
|
117
|
+
setStatus("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const card = createCard("Womp mesh", {
|
|
121
|
+
onTimeout: () => setStatus("Still running — ask me to check on it."),
|
|
122
|
+
onResult: (data: GenerationResult) => {
|
|
123
|
+
if (data.status === "running") {
|
|
124
|
+
$("kind").textContent = "Working";
|
|
125
|
+
$("skeleton").classList.add("pulsing");
|
|
126
|
+
$("actions").hidden = true;
|
|
127
|
+
setStatus("Generating…");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
$("skeleton").classList.remove("pulsing");
|
|
132
|
+
if (data.generationId) {
|
|
133
|
+
$("gid").textContent = "#" + data.generationId;
|
|
134
|
+
$("gid").hidden = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (data.glbUrl) {
|
|
138
|
+
$("kind").textContent = "3D mesh";
|
|
139
|
+
$("actions").hidden = false;
|
|
140
|
+
$("download").hidden = false;
|
|
141
|
+
$("expand").hidden = false;
|
|
142
|
+
mountViewer(data.glbUrl).catch((err) => {
|
|
143
|
+
// Degrade to the preview image rather than an empty box: the GLB may be
|
|
144
|
+
// blocked by CSP, or WebGL may be unavailable in the sandbox.
|
|
145
|
+
setStatus("Could not load the 3D view — " + ((err as Error)?.message ?? err));
|
|
146
|
+
showStill(data.previewUrl || data.imageUrl);
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// get_generation can return an image; this card is also its target, so it
|
|
152
|
+
// has to cope with one.
|
|
153
|
+
$("kind").textContent = "Image";
|
|
154
|
+
$("actions").hidden = false;
|
|
155
|
+
$("download").hidden = !data.imageUrl;
|
|
156
|
+
$("expand").hidden = !data.imageUrl;
|
|
157
|
+
showStill(data.imageUrl);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function showStill(src: string | undefined) {
|
|
162
|
+
if (!src) return;
|
|
163
|
+
const img = $("hero") as HTMLImageElement;
|
|
164
|
+
img.onload = () => {
|
|
165
|
+
$("skeleton").style.display = "none";
|
|
166
|
+
img.style.display = "block";
|
|
167
|
+
};
|
|
168
|
+
img.src = src;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
$("download").addEventListener("click", () => {
|
|
172
|
+
const url = card.current()?.glbUrl || card.current()?.imageUrl;
|
|
173
|
+
if (url) openLink(card.app, url).catch(() => setStatus("Could not open the link."));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$("expand").addEventListener("click", () => {
|
|
177
|
+
toggleFullscreen(card.app).catch(() => setStatus("This host does not support fullscreen."));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Release the WebGL context when the host tears the card down; a leaked context
|
|
181
|
+
// in a hidden iframe is a real cost, and browsers cap how many can exist.
|
|
182
|
+
window.addEventListener("pagehide", () => {
|
|
183
|
+
stopLoop = true;
|
|
184
|
+
controls?.dispose();
|
|
185
|
+
renderer?.dispose();
|
|
186
|
+
});
|