@womp/mcp-ui 0.1.0 → 0.1.2

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.
@@ -63,25 +63,6 @@
63
63
  .skeleton.pulsing { animation: none; }
64
64
  }
65
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
66
  .actions {
86
67
  position: absolute;
87
68
  bottom: 10px;
@@ -147,12 +128,19 @@
147
128
  /* Nothing to reveal on hover when the image fills the screen. */
148
129
  :root[data-display-mode="fullscreen"] .actions { opacity: 1; }
149
130
 
150
- .status:empty { display: none; }
151
- .status {
131
+ .status:empty,
132
+ .meta:empty { display: none; }
133
+ .status,
134
+ .meta {
152
135
  font-size: var(--font-text-xs-size, 11px);
153
136
  color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
154
137
  padding: 8px 12px;
155
138
  }
139
+ /* When both are shown the pair reads as one block, so the meta line drops
140
+ its bottom padding rather than double-spacing them. */
141
+ .meta:has(+ .status:not(:empty)) {
142
+ padding-bottom: 0;
143
+ }
156
144
  </style>
157
145
  </head>
158
146
  <body>
@@ -160,11 +148,6 @@
160
148
  <img id="hero" class="hero" alt="" style="display: none" />
161
149
  <div id="skeleton" class="skeleton"></div>
162
150
 
163
- <div class="chips">
164
- <span class="chip" id="kind">Image</span>
165
- <span class="chip" id="gid" hidden></span>
166
- </div>
167
-
168
151
  <div class="actions" id="actions" hidden>
169
152
  <button class="btn" id="download" title="Open full size" hidden>
170
153
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
@@ -178,6 +161,11 @@
178
161
  </button>
179
162
  </div>
180
163
 
164
+ <!-- Two lines, not one: `meta` is what produced the result, `status` is
165
+ when something went wrong. Sharing an element meant a preview error
166
+ erased the model name, or the reverse, depending on which landed
167
+ last. Both collapse when empty. -->
168
+ <div class="meta" id="meta"></div>
181
169
  <div class="status" id="status">Connecting&hellip;</div>
182
170
  </div>
183
171
  <script type="module" src="./main.ts"></script>
package/src/image/main.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
1
+ import { $, createCard, describeMeta, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
2
2
 
3
3
  /**
4
4
  * The image card — the light one.
@@ -12,6 +12,10 @@ const setStatus = (text: string) => {
12
12
  $("status").textContent = text;
13
13
  };
14
14
 
15
+ const setMeta = (text: string) => {
16
+ $("meta").textContent = text;
17
+ };
18
+
15
19
  function toggle(id: string, on: boolean, title?: string) {
16
20
  const el = $(id);
17
21
  el.hidden = !on;
@@ -22,9 +26,9 @@ const card = createCard("Womp image", {
22
26
  onTimeout: () => setStatus("Still running — ask me to check on it."),
23
27
  onResult: (data: GenerationResult) => {
24
28
  if (data.status === "running") {
25
- $("kind").textContent = "Working";
26
29
  $("skeleton").classList.add("pulsing");
27
30
  $("actions").hidden = true;
31
+ setMeta("");
28
32
  setStatus("Generating…");
29
33
  return;
30
34
  }
@@ -45,11 +49,7 @@ const card = createCard("Womp image", {
45
49
  }
46
50
 
47
51
  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
- }
52
+ setMeta(describeMeta(data));
53
53
 
54
54
  // No "Make 3D" button on purpose. The card could send a user turn via
55
55
  // app.sendMessage(), but the host shows a prompt-injection warning on any
@@ -74,25 +74,6 @@
74
74
  .skeleton.pulsing { animation: none; }
75
75
  }
76
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
77
  .actions {
97
78
  position: absolute;
98
79
  bottom: 10px;
@@ -158,12 +139,19 @@
158
139
  /* Nothing to reveal on hover when the image fills the screen. */
159
140
  :root[data-display-mode="fullscreen"] .actions { opacity: 1; }
160
141
 
161
- .status:empty { display: none; }
162
- .status {
142
+ .status:empty,
143
+ .meta:empty { display: none; }
144
+ .status,
145
+ .meta {
163
146
  font-size: var(--font-text-xs-size, 11px);
164
147
  color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
165
148
  padding: 8px 12px;
166
149
  }
150
+ /* When both are shown the pair reads as one block, so the meta line drops
151
+ its bottom padding rather than double-spacing them. */
152
+ .meta:has(+ .status:not(:empty)) {
153
+ padding-bottom: 0;
154
+ }
167
155
  </style>
168
156
  </head>
169
157
  <body>
@@ -172,11 +160,6 @@
172
160
  <img id="hero" class="hero" alt="" style="display: none" />
173
161
  <div id="skeleton" class="skeleton"></div>
174
162
 
175
- <div class="chips">
176
- <span class="chip" id="kind">Image</span>
177
- <span class="chip" id="gid" hidden></span>
178
- </div>
179
-
180
163
  <div class="actions" id="actions" hidden>
181
164
  <button class="btn" id="download" title="Open full size" hidden>
182
165
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
@@ -190,6 +173,11 @@
190
173
  </button>
191
174
  </div>
192
175
 
176
+ <!-- Two lines, not one: `meta` is what produced the result, `status` is
177
+ when something went wrong. Sharing an element meant a preview error
178
+ erased the model name, or the reverse, depending on which landed
179
+ last. Both collapse when empty. -->
180
+ <div class="meta" id="meta"></div>
193
181
  <div class="status" id="status">Connecting&hellip;</div>
194
182
  </div>
195
183
  <script type="module" src="./main.ts"></script>
package/src/mesh/main.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  Box3,
5
5
  Color,
6
6
  DirectionalLight,
7
+ Group,
7
8
  PerspectiveCamera,
8
9
  Scene,
9
10
  SRGBColorSpace,
@@ -13,7 +14,7 @@ import {
13
14
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
14
15
  import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
15
16
 
16
- import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
17
+ import { $, createCard, describeMeta, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
17
18
 
18
19
  /**
19
20
  * The mesh card — an interactive GLB viewer.
@@ -29,6 +30,10 @@ import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from
29
30
  * blocked request, which is a slow thing to debug.
30
31
  */
31
32
 
33
+ const setMeta = (text: string) => {
34
+ $("meta").textContent = text;
35
+ };
36
+
32
37
  const setStatus = (text: string) => {
33
38
  $("status").textContent = text;
34
39
  };
@@ -47,9 +52,13 @@ function frame(camera: PerspectiveCamera, target: Vector3, size: number) {
47
52
  camera.lookAt(target);
48
53
  }
49
54
 
55
+ /** Matches swan-frontend's MeshViewer: radians per frame, paused while orbiting. */
56
+ const AUTO_ROTATE_STEP = 0.005;
57
+
50
58
  async function mountViewer(url: string) {
51
59
  const canvasHost = $("viewer");
52
- canvasHost.hidden = false;
60
+ // Stays hidden until the model is actually ready. Revealing it here put a
61
+ // second empty box under the skeleton for the whole download.
53
62
 
54
63
  const scene = new Scene();
55
64
  scene.background = null;
@@ -78,6 +87,16 @@ async function mountViewer(url: string) {
78
87
  controls.enableDamping = true;
79
88
  controls.enablePan = false;
80
89
 
90
+ // Auto-rotation yields to the user the moment they grab the model and
91
+ // resumes when they let go — same behaviour as swan-frontend's MeshViewer.
92
+ let userInteracting = false;
93
+ controls.addEventListener("start", () => {
94
+ userInteracting = true;
95
+ });
96
+ controls.addEventListener("end", () => {
97
+ userInteracting = false;
98
+ });
99
+
81
100
  const resize = () => {
82
101
  const w = canvasHost.clientWidth || 360;
83
102
  const h = canvasHost.clientHeight || 360;
@@ -93,26 +112,33 @@ async function mountViewer(url: string) {
93
112
  if (ev.total) setStatus(`Loading model… ${Math.round((ev.loaded / ev.total) * 100)}%`);
94
113
  });
95
114
 
96
- scene.add(gltf.scene);
97
-
98
115
  // 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.
116
+ // origin-centred, and an off-centre pivot makes orbiting feel broken. The
117
+ // pivot group is what auto-rotation spins — rotating gltf.scene itself would
118
+ // orbit the mesh around its original off-centre origin and wobble.
100
119
  const box = new Box3().setFromObject(gltf.scene);
101
120
  const centre = box.getCenter(new Vector3());
102
121
  const size = box.getSize(new Vector3()).length();
103
122
  gltf.scene.position.sub(centre);
123
+ const pivot = new Group();
124
+ pivot.add(gltf.scene);
125
+ scene.add(pivot);
104
126
  controls.target.set(0, 0, 0);
105
127
  frame(camera, new Vector3(0, 0, 0), size);
106
128
  controls.update();
107
129
 
108
130
  const loop = () => {
109
131
  if (stopLoop) return;
132
+ if (!userInteracting) pivot.rotation.y += AUTO_ROTATE_STEP;
110
133
  controls!.update();
111
134
  renderer!.render(scene, camera);
112
135
  requestAnimationFrame(loop);
113
136
  };
114
137
  loop();
115
138
 
139
+ // The swap: model is loaded and framed, so the placeholder gives way to the
140
+ // canvas in one step — never both at once.
141
+ canvasHost.hidden = false;
116
142
  $("skeleton").style.display = "none";
117
143
  setStatus("");
118
144
  }
@@ -121,27 +147,26 @@ const card = createCard("Womp mesh", {
121
147
  onTimeout: () => setStatus("Still running — ask me to check on it."),
122
148
  onResult: (data: GenerationResult) => {
123
149
  if (data.status === "running") {
124
- $("kind").textContent = "Working";
125
150
  $("skeleton").classList.add("pulsing");
126
151
  $("actions").hidden = true;
152
+ setMeta("");
127
153
  setStatus("Generating…");
128
154
  return;
129
155
  }
130
156
 
131
157
  $("skeleton").classList.remove("pulsing");
132
- if (data.generationId) {
133
- $("gid").textContent = "#" + data.generationId;
134
- $("gid").hidden = false;
135
- }
158
+ // Replaces the old #<generationId> chip: an internal id tells the reader
159
+ // nothing, the time and the model do.
160
+ setMeta(describeMeta(data));
136
161
 
137
162
  if (data.glbUrl) {
138
- $("kind").textContent = "3D mesh";
139
163
  $("actions").hidden = false;
140
164
  $("download").hidden = false;
141
165
  $("expand").hidden = false;
142
166
  mountViewer(data.glbUrl).catch((err) => {
143
167
  // Degrade to the preview image rather than an empty box: the GLB may be
144
168
  // blocked by CSP, or WebGL may be unavailable in the sandbox.
169
+ $("viewer").hidden = true;
145
170
  setStatus("Could not load the 3D view — " + ((err as Error)?.message ?? err));
146
171
  showStill(data.previewUrl || data.imageUrl);
147
172
  });
@@ -150,7 +175,6 @@ const card = createCard("Womp mesh", {
150
175
 
151
176
  // get_generation can return an image; this card is also its target, so it
152
177
  // has to cope with one.
153
- $("kind").textContent = "Image";
154
178
  $("actions").hidden = false;
155
179
  $("download").hidden = !data.imageUrl;
156
180
  $("expand").hidden = !data.imageUrl;
@@ -33,6 +33,27 @@ export interface GenerationResult {
33
33
  imageUrl?: string;
34
34
  glbUrl?: string;
35
35
  previewUrl?: string;
36
+ /** `AiWorkflow.name`, e.g. "Nano Banana 2". */
37
+ model?: string;
38
+ /** The provider's own generation time, in seconds. */
39
+ durationSeconds?: number;
40
+ }
41
+
42
+ /**
43
+ * "8.77s · Nano Banana 2" — what produced this result and how long it took,
44
+ * matching swan-frontend's generation tile (`OperationModelTags.tsx`), down to
45
+ * the two decimal places.
46
+ *
47
+ * Returns "" when the backend sent neither, which is a normal outcome: an older
48
+ * swan-backend does not send these fields at all, and not every provider
49
+ * reports a time. Both call sites treat "" as "hide the line" rather than
50
+ * printing a lonely separator or a bare "0s".
51
+ */
52
+ export function describeMeta(data: GenerationResult): string {
53
+ const parts: string[] = [];
54
+ if (typeof data.durationSeconds === "number") parts.push(`${data.durationSeconds.toFixed(2)}s`);
55
+ if (data.model) parts.push(data.model);
56
+ return parts.join(" · ");
36
57
  }
37
58
 
38
59
  export const $ = (id: string) => document.getElementById(id) as HTMLElement;
@@ -78,6 +99,10 @@ export function createCard(name: string, hooks: CardHooks): Card {
78
99
  }
79
100
  };
80
101
 
102
+ // autoResize is on by default (the App constructor's third parameter defaults
103
+ // to `{ autoResize: true }`), which is what sends `ui/notifications/size-changed`
104
+ // as the card grows. Note it runs inside requestAnimationFrame, so it does not
105
+ // fire in a tab that is not being rendered — a harness caveat, not a bug.
81
106
  const app = new App({ name, version: "0.0.1" }, {});
82
107
 
83
108
  const fetchGeneration = async (operationId: number) => {