@ottotheagent/mcp-card-assets 0.5.0 → 0.7.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/README.md +2 -2
- package/car-card.js +20 -3
- package/flight-card.js +16 -3
- package/hotel-card.js +20 -3
- package/package.json +1 -1
- package/room-card.js +22 -5
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# @ottotheagent/mcp-card-assets
|
|
2
2
|
|
|
3
|
-
**Created**: 2026-10-02 | **Last Updated**: 2026-10-
|
|
3
|
+
**Created**: 2026-10-02 | **Last Updated**: 2026-10-05
|
|
4
4
|
|
|
5
5
|
Renderers, logos and photo placeholders for the Otto flight, hotel, room and car cards. Each `render_*` MCP tool returns card data and a snippet. The host's model mounts the card in its own widget sandbox, which loads files only from a few public CDNs, so the assets are served from jsDelivr.
|
|
6
6
|
|
|
7
7
|
## Contents
|
|
8
8
|
|
|
9
9
|
- `flight-card.js`, `hotel-card.js`, `room-card.js` and `car-card.js` each draw one card and expose `OttoFlightCard`, `OttoHotelCard`, `OttoRoomCard` or `OttoCarCard` with `render(rootElement, card)`. Each carries its own CSS and only the icons it uses inline, so the sandbox loads one script per card. The server's snippet loads jsDelivr's minified copy (`*-card.min.js`).
|
|
10
|
-
- Select (Show rooms on the hotel card) sends the pick as the traveler's message through the host's `sendPrompt()`: "Book flight …", "Show rooms at …", "
|
|
10
|
+
- Select (Show rooms on the hotel card) sends the pick as the traveler's message through the host's `sendPrompt()`: "Book flight …", "Show rooms at …", "Select room …" or "Book car …". The message carries no id.
|
|
11
11
|
- `*-card.css` holds each card's CSS as a separate file, for reference.
|
|
12
12
|
- `marks/<IATA>.png` holds airline logos, and `marks/cars/<code>.svg` holds car-company logos.
|
|
13
13
|
- `placeholders/hotel.svg`, `room.svg` and `car.svg` fill the photo plate, because host sandboxes block supplier photos.
|
package/car-card.js
CHANGED
|
@@ -43,6 +43,15 @@ function icon(names, fallback, className = "icon") {
|
|
|
43
43
|
return node;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The pick message quotes the exact price, so the model can tell apart options that round alike.
|
|
47
|
+
function formatExactMoney(value, currency) {
|
|
48
|
+
if (value === null || value === undefined) return "";
|
|
49
|
+
const symbol = SYMBOLS[currency];
|
|
50
|
+
const fraction = Number.isInteger(value) ? 0 : 2;
|
|
51
|
+
const amount = value.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: 2 });
|
|
52
|
+
return symbol ? `${symbol}${amount}` : `${currency} ${amount}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function formatMoney(value, currency) {
|
|
47
56
|
if (value === null || value === undefined) return "";
|
|
48
57
|
const symbol = SYMBOLS[currency];
|
|
@@ -87,6 +96,9 @@ function renderInsights(reasons, notes) {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
function mount(root, card, spec) {
|
|
99
|
+
// A second mount on the same root replaces the first card; its lock timer must not
|
|
100
|
+
// fire later and redraw over the new card.
|
|
101
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
90
102
|
let chosen = null;
|
|
91
103
|
let busy = false;
|
|
92
104
|
let timer = null;
|
|
@@ -128,7 +140,8 @@ function mount(root, card, spec) {
|
|
|
128
140
|
|
|
129
141
|
// One pick at a time: the new pick replaces the old mark, then the card locks so a
|
|
130
142
|
// second click cannot send a second message.
|
|
131
|
-
|
|
143
|
+
// Assigned, not added: a re-mount replaces the handler instead of stacking another.
|
|
144
|
+
root.onclick = (event) => {
|
|
132
145
|
if (busy) return;
|
|
133
146
|
const button = event.target.closest("[data-select]");
|
|
134
147
|
const item = button && card.items.find((i) => i.id === button.dataset.select);
|
|
@@ -143,9 +156,12 @@ function mount(root, card, spec) {
|
|
|
143
156
|
} catch (error) {
|
|
144
157
|
unlock();
|
|
145
158
|
}
|
|
146
|
-
}
|
|
159
|
+
};
|
|
147
160
|
|
|
148
161
|
draw();
|
|
162
|
+
const handle = { cancel: () => clearTimeout(timer) };
|
|
163
|
+
root.__ottoCard = handle;
|
|
164
|
+
return handle;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
/* Otto car card: OttoCarCard.render(root, card), card from render_cars.
|
|
@@ -228,6 +244,7 @@ function lineIcon(name) {
|
|
|
228
244
|
function render(root, card) {
|
|
229
245
|
injectCss("otto-car-card-css");
|
|
230
246
|
const money = (value, item) => formatMoney(value, item?.currency || card.currency);
|
|
247
|
+
const exactMoney = (value, item) => formatExactMoney(value, item?.currency || card.currency);
|
|
231
248
|
const chips = assignChips(card.items || [], carCallouts(card.items || []));
|
|
232
249
|
|
|
233
250
|
// Vendor and class as a traveler would say them; the card number only when another
|
|
@@ -236,7 +253,7 @@ function render(root, card) {
|
|
|
236
253
|
const name = oneLine(`${item.vendor} ${item.class_label}`);
|
|
237
254
|
const key = (other) => oneLine(`${other.vendor} ${other.class_label}`).toLowerCase();
|
|
238
255
|
const twin = card.items.some((other) => other !== item && key(other) === key(item));
|
|
239
|
-
const total =
|
|
256
|
+
const total = exactMoney(item.total_rate, item);
|
|
240
257
|
return [twin ? `${name} (No. ${card.items.indexOf(item) + 1} on the card)` : name, total ? `${total} total` : ""]
|
|
241
258
|
.filter(Boolean)
|
|
242
259
|
.join(", ");
|
package/flight-card.js
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
function render(root, card) {
|
|
34
34
|
injectCss();
|
|
35
|
+
// A second render into the same root replaces the first card, so the first one's
|
|
36
|
+
// lock timer must not fire later and redraw over the new card.
|
|
37
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
35
38
|
let open = null;
|
|
36
39
|
let chosen = null;
|
|
37
40
|
let busy = false;
|
|
@@ -41,6 +44,12 @@
|
|
|
41
44
|
value === null || value === undefined
|
|
42
45
|
? ""
|
|
43
46
|
: (SYMBOLS[card.currency] || card.currency + " ") + Math.round(value).toLocaleString();
|
|
47
|
+
// The message quotes the exact price, so the model can tell apart fares that round alike.
|
|
48
|
+
const exactMoney = (value) =>
|
|
49
|
+
value === null || value === undefined
|
|
50
|
+
? ""
|
|
51
|
+
: (SYMBOLS[card.currency] || card.currency + " ") +
|
|
52
|
+
value.toLocaleString("en-US", { minimumFractionDigits: Number.isInteger(value) ? 0 : 2, maximumFractionDigits: 2 });
|
|
44
53
|
const duration = (m) => (m === null || m === undefined ? "" : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`);
|
|
45
54
|
const stops = (s) => (!s ? "" : s === 1 ? "1 stop" : `${s} stops`);
|
|
46
55
|
const recommended = (option) => option.fares.find((f) => f.recommended) || option.fares[0];
|
|
@@ -65,7 +74,7 @@
|
|
|
65
74
|
option.depart ? `departing ${oneLine(option.depart, 12)}` : "",
|
|
66
75
|
oneLine(fare.name),
|
|
67
76
|
oneLine(fare.refundable),
|
|
68
|
-
|
|
77
|
+
exactMoney(fare.price),
|
|
69
78
|
].filter(Boolean);
|
|
70
79
|
return `Book flight ${parts.join(", ")}`;
|
|
71
80
|
}
|
|
@@ -222,7 +231,8 @@
|
|
|
222
231
|
}
|
|
223
232
|
}
|
|
224
233
|
|
|
225
|
-
|
|
234
|
+
// Assigned, not added: a re-render replaces the handler instead of stacking another.
|
|
235
|
+
root.onclick = (event) => {
|
|
226
236
|
if (busy) return;
|
|
227
237
|
const button = event.target.closest("[data-select]");
|
|
228
238
|
if (button) {
|
|
@@ -236,9 +246,12 @@
|
|
|
236
246
|
const id = opener.dataset.fares || opener.dataset.card;
|
|
237
247
|
open = open === id ? null : id;
|
|
238
248
|
draw();
|
|
239
|
-
}
|
|
249
|
+
};
|
|
240
250
|
|
|
241
251
|
draw();
|
|
252
|
+
const handle = { cancel: () => clearTimeout(timer) };
|
|
253
|
+
root.__ottoCard = handle;
|
|
254
|
+
return handle;
|
|
242
255
|
}
|
|
243
256
|
|
|
244
257
|
window.OttoFlightCard = { render };
|
package/hotel-card.js
CHANGED
|
@@ -43,6 +43,15 @@ function icon(names, fallback, className = "icon") {
|
|
|
43
43
|
return node;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The pick message quotes the exact price, so the model can tell apart options that round alike.
|
|
47
|
+
function formatExactMoney(value, currency) {
|
|
48
|
+
if (value === null || value === undefined) return "";
|
|
49
|
+
const symbol = SYMBOLS[currency];
|
|
50
|
+
const fraction = Number.isInteger(value) ? 0 : 2;
|
|
51
|
+
const amount = value.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: 2 });
|
|
52
|
+
return symbol ? `${symbol}${amount}` : `${currency} ${amount}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function formatMoney(value, currency) {
|
|
47
56
|
if (value === null || value === undefined) return "";
|
|
48
57
|
const symbol = SYMBOLS[currency];
|
|
@@ -87,6 +96,9 @@ function renderInsights(reasons, notes) {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
function mount(root, card, spec) {
|
|
99
|
+
// A second mount on the same root replaces the first card; its lock timer must not
|
|
100
|
+
// fire later and redraw over the new card.
|
|
101
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
90
102
|
let chosen = null;
|
|
91
103
|
let busy = false;
|
|
92
104
|
let timer = null;
|
|
@@ -128,7 +140,8 @@ function mount(root, card, spec) {
|
|
|
128
140
|
|
|
129
141
|
// One pick at a time: the new pick replaces the old mark, then the card locks so a
|
|
130
142
|
// second click cannot send a second message.
|
|
131
|
-
|
|
143
|
+
// Assigned, not added: a re-mount replaces the handler instead of stacking another.
|
|
144
|
+
root.onclick = (event) => {
|
|
132
145
|
if (busy) return;
|
|
133
146
|
const button = event.target.closest("[data-select]");
|
|
134
147
|
const item = button && card.items.find((i) => i.id === button.dataset.select);
|
|
@@ -143,9 +156,12 @@ function mount(root, card, spec) {
|
|
|
143
156
|
} catch (error) {
|
|
144
157
|
unlock();
|
|
145
158
|
}
|
|
146
|
-
}
|
|
159
|
+
};
|
|
147
160
|
|
|
148
161
|
draw();
|
|
162
|
+
const handle = { cancel: () => clearTimeout(timer) };
|
|
163
|
+
root.__ottoCard = handle;
|
|
164
|
+
return handle;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
/* Otto hotel card: OttoHotelCard.render(root, card), card from render_hotels.
|
|
@@ -177,6 +193,7 @@ function hotelCallouts(items) {
|
|
|
177
193
|
function render(root, card) {
|
|
178
194
|
injectCss("otto-hotel-card-css");
|
|
179
195
|
const money = (value, item) => formatMoney(value, item?.currency || card.currency);
|
|
196
|
+
const exactMoney = (value, item) => formatExactMoney(value, item?.currency || card.currency);
|
|
180
197
|
const labels = hotelCallouts(card.items || []);
|
|
181
198
|
|
|
182
199
|
// The hotel's name, as a traveler would say it. A detail is added only when the name
|
|
@@ -189,7 +206,7 @@ function render(root, card) {
|
|
|
189
206
|
if (!twins.length && name.length >= MIN_NAME_CHARS) return name;
|
|
190
207
|
const details = (item) => [
|
|
191
208
|
oneLine(item.address || "", 60),
|
|
192
|
-
item.nightly_rate === null || item.nightly_rate === undefined ? "" : `from ${
|
|
209
|
+
item.nightly_rate === null || item.nightly_rate === undefined ? "" : `from ${exactMoney(item.nightly_rate, item)}/night`,
|
|
193
210
|
`No. ${card.items.indexOf(item) + 1} on the card`,
|
|
194
211
|
];
|
|
195
212
|
const detail = details(option).find((value, i) => value && twins.every((twin) => details(twin)[i] !== value));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottotheagent/mcp-card-assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Renderers, styles, logos and photo placeholders for the Otto flight, hotel, room and car cards that Claude draws in its widget sandbox.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"files": [
|
package/room-card.js
CHANGED
|
@@ -43,6 +43,15 @@ function icon(names, fallback, className = "icon") {
|
|
|
43
43
|
return node;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The pick message quotes the exact price, so the model can tell apart options that round alike.
|
|
47
|
+
function formatExactMoney(value, currency) {
|
|
48
|
+
if (value === null || value === undefined) return "";
|
|
49
|
+
const symbol = SYMBOLS[currency];
|
|
50
|
+
const fraction = Number.isInteger(value) ? 0 : 2;
|
|
51
|
+
const amount = value.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: 2 });
|
|
52
|
+
return symbol ? `${symbol}${amount}` : `${currency} ${amount}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function formatMoney(value, currency) {
|
|
47
56
|
if (value === null || value === undefined) return "";
|
|
48
57
|
const symbol = SYMBOLS[currency];
|
|
@@ -87,6 +96,9 @@ function renderInsights(reasons, notes) {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
function mount(root, card, spec) {
|
|
99
|
+
// A second mount on the same root replaces the first card; its lock timer must not
|
|
100
|
+
// fire later and redraw over the new card.
|
|
101
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
90
102
|
let chosen = null;
|
|
91
103
|
let busy = false;
|
|
92
104
|
let timer = null;
|
|
@@ -128,7 +140,8 @@ function mount(root, card, spec) {
|
|
|
128
140
|
|
|
129
141
|
// One pick at a time: the new pick replaces the old mark, then the card locks so a
|
|
130
142
|
// second click cannot send a second message.
|
|
131
|
-
|
|
143
|
+
// Assigned, not added: a re-mount replaces the handler instead of stacking another.
|
|
144
|
+
root.onclick = (event) => {
|
|
132
145
|
if (busy) return;
|
|
133
146
|
const button = event.target.closest("[data-select]");
|
|
134
147
|
const item = button && card.items.find((i) => i.id === button.dataset.select);
|
|
@@ -143,13 +156,16 @@ function mount(root, card, spec) {
|
|
|
143
156
|
} catch (error) {
|
|
144
157
|
unlock();
|
|
145
158
|
}
|
|
146
|
-
}
|
|
159
|
+
};
|
|
147
160
|
|
|
148
161
|
draw();
|
|
162
|
+
const handle = { cancel: () => clearTimeout(timer) };
|
|
163
|
+
root.__ottoCard = handle;
|
|
164
|
+
return handle;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
/* Otto room card: OttoRoomCard.render(root, card), card from render_hotel_rooms.
|
|
152
|
-
* Select sends "
|
|
168
|
+
* Select sends "Select room …" as the traveler's message. */
|
|
153
169
|
|
|
154
170
|
// Fact icon → [icon name, text fallback].
|
|
155
171
|
const FACT_ICONS = {
|
|
@@ -161,10 +177,11 @@ const FACT_ICONS = {
|
|
|
161
177
|
function render(root, card) {
|
|
162
178
|
injectCss("otto-room-card-css");
|
|
163
179
|
const money = (value, item) => formatMoney(value, item?.currency || card.currency);
|
|
180
|
+
const exactMoney = (value, item) => formatExactMoney(value, item?.currency || card.currency);
|
|
164
181
|
|
|
165
182
|
// The pick as a traveler would say it: room, hotel, rate, and its refund terms.
|
|
166
183
|
function describe(item) {
|
|
167
|
-
const price =
|
|
184
|
+
const price = exactMoney(item.nightly_rate, item);
|
|
168
185
|
const refund = (item.facts || []).find((fact) => fact.icon === "refund");
|
|
169
186
|
return [oneLine(item.name), oneLine(card.title), price ? `${price}/night` : "", refund ? oneLine(refund.text) : ""]
|
|
170
187
|
.filter(Boolean)
|
|
@@ -219,7 +236,7 @@ function render(root, card) {
|
|
|
219
236
|
mount(root, card, {
|
|
220
237
|
empty: "No room options to show.",
|
|
221
238
|
option,
|
|
222
|
-
message: (item) => `
|
|
239
|
+
message: (item) => `Select room ${describe(item)}`,
|
|
223
240
|
});
|
|
224
241
|
}
|
|
225
242
|
|