@ottotheagent/mcp-card-assets 0.5.0 → 0.6.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/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
- root.addEventListener("click", (event) => {
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 = money(item.total_rate, item);
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
- money(fare.price),
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
- root.addEventListener("click", (event) => {
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
- root.addEventListener("click", (event) => {
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 ${money(item.nightly_rate, item)}/night`,
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.5.0",
3
+ "version": "0.6.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
- root.addEventListener("click", (event) => {
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 room card: OttoRoomCard.render(root, card), card from render_hotel_rooms.
@@ -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 = money(item.nightly_rate, item);
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)