@ottotheagent/mcp-card-assets 0.10.0 → 0.11.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 +4 -4
- package/car-card.css +1 -1
- package/car-card.js +34 -164
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
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-06
|
|
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
|
-
- `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`). Since 0.10.0, `room-card.js` draws only a row of room buttons: the host draws the rooms, with photos, from the same tool's MCP Apps resource.
|
|
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 "
|
|
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`). Since 0.10.0, `room-card.js` draws only a row of room buttons, and since 0.11.0 `car-card.js` draws only a row of car buttons: the host draws the rooms or cars, with photos, from the same tool's MCP Apps resource.
|
|
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 "Select 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
|
-
- `placeholders
|
|
13
|
+
- `placeholders/` holds photo placeholders for renderers before 0.8.0 (`hotel.svg`), 0.10.0 (`room.svg`) and 0.11.0 (`car.svg`). Current renderers draw no photo plate: host sandboxes block supplier photos, so the host's own map or MCP Apps carousel shows them.
|
|
14
14
|
|
|
15
15
|
The sources are `src/flight-card.js`, and `src/core.js` plus `src/<kind>-card.js` for the hotel, room and car cards. `scripts/mcp/build_card_assets.py` writes the published files from them, from the card CSS in `server/mcp/apps/static/`, and from the logo tables. The generated files are not committed; `placeholders/` is.
|
|
16
16
|
|
package/car-card.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme="dark"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}.btn.chosen:disabled{opacity:1;}#root{min-height:
|
|
1
|
+
:root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme="dark"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}.btn.chosen:disabled{opacity:1;}#root{min-height:0;}.bar{display:flex;flex-wrap:wrap;gap:var(--otto-space-3);}.bar .btn{max-width:100%;}.bar .label{overflow:hidden;text-overflow:ellipsis;}.btn.chosen{background:var(--otto-text-primary);color:var(--otto-card-bg);border-color:var(--otto-text-primary);}
|
package/car-card.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* spec.bar draws the options as one wrapping row of buttons with no title, for a card
|
|
12
12
|
* whose details the host already shows above it.
|
|
13
13
|
*/
|
|
14
|
-
const CSS = ":root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme=\"dark\"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}.btn.chosen:disabled{opacity:1;}#root{min-height:
|
|
15
|
-
const ICONS = {"
|
|
14
|
+
const CSS = ":root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme=\"dark\"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}.btn.chosen:disabled{opacity:1;}#root{min-height:0;}.bar{display:flex;flex-wrap:wrap;gap:var(--otto-space-3);}.bar .btn{max-width:100%;}.bar .label{overflow:hidden;text-overflow:ellipsis;}.btn.chosen{background:var(--otto-text-primary);color:var(--otto-card-bg);border-color:var(--otto-text-primary);}";
|
|
15
|
+
const ICONS = {"hotels/check-regular-full":"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M534 132.5C544.8 140.2 547.2 155.2 539.5 166L275.5 534C271.4 539.7 265 543.4 258 543.9C251 544.4 244 542 239 537L103 401C93.6 391.6 93.6 376.4 103 367.1C112.4 357.8 127.6 357.7 136.9 367.1L253 483L500.5 138C508.2 127.2 523.2 124.8 534 132.5z\"/></svg>","hotels/circle-info-regular-full":"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM280 400C266.7 400 256 410.7 256 424C256 437.3 266.7 448 280 448L360 448C373.3 448 384 437.3 384 424C384 410.7 373.3 400 360 400L352 400L352 312C352 298.7 341.3 288 328 288L280 288C266.7 288 256 298.7 256 312C256 325.3 266.7 336 280 336L304 336L304 400L280 400zM320 256C337.7 256 352 241.7 352 224C352 206.3 337.7 192 320 192C302.3 192 288 206.3 288 224C288 241.7 302.3 256 320 256z\"/></svg>"};
|
|
16
16
|
const SYMBOLS = { USD: "$", EUR: "€", GBP: "£", JPY: "¥", CNY: "¥", AUD: "A$", CAD: "C$" };
|
|
17
17
|
// The widget cannot see when the host's reply ends, so the lock lifts on a timer.
|
|
18
18
|
const BUSY_MS = 60000;
|
|
@@ -169,183 +169,53 @@ function mount(root, card, spec) {
|
|
|
169
169
|
return handle;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
/* Otto car
|
|
173
|
-
*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
177
|
-
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
178
|
-
|
|
179
|
-
// "2026-10-03T12:00:00" (pickup-local) → "Oct 3". Read off the string, not through Date,
|
|
180
|
-
// which would shift it into the viewer's time zone.
|
|
181
|
-
function shortDate(iso) {
|
|
182
|
-
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || "");
|
|
183
|
-
return match ? `${MONTHS[Number(match[2]) - 1]} ${Number(match[3])}` : "";
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Facts about the shown set, not a ranking: every option tied for the best value is a
|
|
187
|
-
// candidate, and none is when all are tied. Totals are compared only in the card's currency.
|
|
188
|
-
function carCallouts(items) {
|
|
189
|
-
const byId = new Map(items.map((item) => [item.id, []]));
|
|
190
|
-
const award = (label, value, pick) => {
|
|
191
|
-
const values = items.map(value).filter((v) => typeof v === "number");
|
|
192
|
-
if (values.length < 2) return;
|
|
193
|
-
const best = pick(...values);
|
|
194
|
-
if (values.every((v) => v === best)) return;
|
|
195
|
-
for (const item of items) if (value(item) === best) byId.get(item.id).push(label);
|
|
196
|
-
};
|
|
197
|
-
award("Lowest total", (item) => (item.currency ? null : item.total_rate), Math.min);
|
|
198
|
-
award("Most bags", (item) => item.bags, Math.max);
|
|
199
|
-
return byId;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// One chip per card, each chip text on one card only, in card order; the policy
|
|
203
|
-
// warning outranks everything and is exempt from the one-card rule.
|
|
204
|
-
function assignChips(items, labels) {
|
|
205
|
-
const taken = new Set();
|
|
206
|
-
const byId = new Map();
|
|
207
|
-
for (const item of items) {
|
|
208
|
-
const candidates = [];
|
|
209
|
-
if (item.within_policy === false) candidates.push([OUT_OF_POLICY, "chip caution"]);
|
|
210
|
-
if (item.company_preference_label) candidates.push([item.company_preference_label, "chip"]);
|
|
211
|
-
if (item.is_corporate_rate) candidates.push(["Corporate rate", "chip"]);
|
|
212
|
-
for (const label of labels.get(item.id) || []) candidates.push([label, "chip"]);
|
|
213
|
-
const chip = candidates.find(([text]) => !taken.has(text));
|
|
214
|
-
if (chip && chip[0] !== OUT_OF_POLICY) taken.add(chip[0]);
|
|
215
|
-
byId.set(item.id, chip || null);
|
|
216
|
-
}
|
|
217
|
-
return byId;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function svg(tag, attrs) {
|
|
221
|
-
const node = document.createElementNS(SVG_NS, tag);
|
|
222
|
-
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
223
|
-
return node;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Drawn line icons in the text colour, for a name the icon table lacks.
|
|
227
|
-
const STROKE = { fill: "none", stroke: "currentColor", "stroke-width": "1.3", "stroke-linecap": "round" };
|
|
228
|
-
const LINE_ICONS = {
|
|
229
|
-
seats: [
|
|
230
|
-
["circle", { cx: "6", cy: "5", r: "2.3" }],
|
|
231
|
-
["path", { d: "M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4" }],
|
|
232
|
-
["circle", { cx: "11.4", cy: "5.6", r: "1.8" }],
|
|
233
|
-
["path", { d: "M11.2 9.4c1.8.1 3 1.5 3 3.6" }],
|
|
234
|
-
],
|
|
235
|
-
bags: [
|
|
236
|
-
["rect", { x: "3.5", y: "4.5", width: "9", height: "9.5", rx: "1.5" }],
|
|
237
|
-
["path", { d: "M6 4.5V2.5h4v2M6.2 7.5v4M9.8 7.5v4" }],
|
|
238
|
-
],
|
|
239
|
-
};
|
|
240
|
-
const TABLE_ICONS = { seats: "cars/person-seat-reclined-solid-full", bags: "cars/suitcase-regular-full" };
|
|
241
|
-
|
|
242
|
-
function lineIcon(name) {
|
|
243
|
-
if (ICONS[TABLE_ICONS[name]]) return icon(TABLE_ICONS[name]);
|
|
244
|
-
const node = svg("svg", { width: "15", height: "15", viewBox: "0 0 16 16", "aria-hidden": "true" });
|
|
245
|
-
for (const [tag, attrs] of LINE_ICONS[name]) node.append(svg(tag, { ...STROKE, ...attrs }));
|
|
246
|
-
return node;
|
|
247
|
-
}
|
|
172
|
+
/* Otto car buttons: OttoCarCard.render(root, card), card from render_elastic_cars.
|
|
173
|
+
* One button per car, labeled with its company and class; a click sends "Select car …" as
|
|
174
|
+
* the traveler's message. The host draws the cars themselves, with photos, from the same
|
|
175
|
+
* tool result (see the car_rendering_elastic skill). */
|
|
248
176
|
|
|
249
177
|
function render(root, card) {
|
|
250
178
|
injectCss("otto-car-card-css");
|
|
251
|
-
const money = (value, item) => formatMoney(value, item?.currency || card.currency);
|
|
252
179
|
const exactMoney = (value, item) => formatExactMoney(value, item?.currency || card.currency);
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const name = oneLine(`${item.vendor} ${item.class_label}`);
|
|
259
|
-
const key = (other) => oneLine(`${other.vendor} ${other.class_label}`).toLowerCase();
|
|
260
|
-
const twin = card.items.some((other) => other !== item && key(other) === key(item));
|
|
261
|
-
const total = exactMoney(item.total_rate, item);
|
|
262
|
-
return [twin ? `${name} (No. ${card.items.indexOf(item) + 1} on the card)` : name, total ? `${total} total` : ""]
|
|
263
|
-
.filter(Boolean)
|
|
264
|
-
.join(", ");
|
|
265
|
-
}
|
|
180
|
+
const name = (item) => oneLine(`${item.vendor} ${item.class_label}`);
|
|
181
|
+
const total = (item) => {
|
|
182
|
+
const amount = exactMoney(item.total_rate, item);
|
|
183
|
+
return amount ? `${amount} total` : "";
|
|
184
|
+
};
|
|
266
185
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
img.addEventListener("error", () => img.replaceWith(el("div", "vendorName", item.vendor)));
|
|
274
|
-
return img;
|
|
186
|
+
// The pick as a traveler would say it: company, class and total, plus the card
|
|
187
|
+
// position when another car reads the same, so the model can tell them apart.
|
|
188
|
+
function describe(item) {
|
|
189
|
+
const text = (other) => [name(other), total(other)].filter(Boolean).join(", ");
|
|
190
|
+
const twin = card.items.some((other) => other !== item && text(other).toLowerCase() === text(item).toLowerCase());
|
|
191
|
+
return twin ? `${text(item)} (No. ${card.items.indexOf(item) + 1} on the card)` : text(item);
|
|
275
192
|
}
|
|
276
193
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const noted = [];
|
|
287
|
-
if (item.refundable === true) {
|
|
288
|
-
const until = shortDate(item.free_cancellation_until);
|
|
289
|
-
ticked.push(until ? `Free cancellation until ${until}` : "Free cancellation");
|
|
290
|
-
} else if (item.refundable === false) {
|
|
291
|
-
noted.push("Non-refundable");
|
|
292
|
-
}
|
|
293
|
-
// search_cars returns pay-at-pickup rates only; nothing is charged at booking.
|
|
294
|
-
ticked.push("Pay at pickup");
|
|
295
|
-
if (item.concern) noted.push(item.concern);
|
|
296
|
-
return [ticked, noted];
|
|
194
|
+
// Company and class, plus the first of total and position that no same-named car
|
|
195
|
+
// shares: a vendor can list one class at several rates.
|
|
196
|
+
function label(item) {
|
|
197
|
+
const key = (other) => name(other).toLowerCase();
|
|
198
|
+
const twins = card.items.filter((other) => other !== item && key(other) === key(item));
|
|
199
|
+
if (!twins.length) return name(item);
|
|
200
|
+
const details = (other) => [total(other), `No. ${card.items.indexOf(other) + 1}`];
|
|
201
|
+
const detail = details(item).find((value, i) => value && twins.every((twin) => details(twin)[i] !== value));
|
|
202
|
+
return detail ? `${name(item)} · ${detail}` : name(item);
|
|
297
203
|
}
|
|
298
204
|
|
|
299
205
|
function option(item, ui) {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
row.append(el("span", chip[1], chip[0]));
|
|
307
|
-
photo.append(row);
|
|
308
|
-
}
|
|
309
|
-
photo.append(vendor(item));
|
|
310
|
-
box.append(photo, el("div", "name", item.class_label));
|
|
311
|
-
|
|
312
|
-
// The vendor's program, not a promise of points: earning depends on the rental.
|
|
313
|
-
if (item.loyalty_program) {
|
|
314
|
-
const loyalty = el("div", "loyalty");
|
|
315
|
-
loyalty.append(icon("hotels/coin-light-full", "◎"), el("span", null, `Eligible for ${item.loyalty_program}`));
|
|
316
|
-
box.append(loyalty);
|
|
317
|
-
}
|
|
318
|
-
const features = el("div", "features");
|
|
319
|
-
if (item.seats) features.append(fact("seats", item.seats === 1 ? "1 seat" : `${item.seats} seats`));
|
|
320
|
-
if (item.bags) features.append(fact("bags", item.bags === 1 ? "1 bag" : `${item.bags} bags`));
|
|
321
|
-
if (features.childElementCount) box.append(features);
|
|
322
|
-
|
|
323
|
-
const fare = el("div", "fare");
|
|
324
|
-
const price = el("div");
|
|
325
|
-
// The per-day rate is the pre-tax base; the total is what the counter charges.
|
|
326
|
-
if (item.daily_rate !== null && item.daily_rate !== undefined) {
|
|
327
|
-
const amount = el("div", "amount", money(item.daily_rate, item));
|
|
328
|
-
amount.append(el("span", "per", "/day"));
|
|
329
|
-
price.append(amount);
|
|
330
|
-
if (item.total_rate !== null && item.total_rate !== undefined) {
|
|
331
|
-
price.append(el("span", "total", `${money(item.total_rate, item)} total`));
|
|
332
|
-
}
|
|
333
|
-
} else if (item.total_rate !== null && item.total_rate !== undefined) {
|
|
334
|
-
const amount = el("div", "amount", money(item.total_rate, item));
|
|
335
|
-
amount.append(el("span", "per", " total"));
|
|
336
|
-
price.append(amount);
|
|
337
|
-
}
|
|
338
|
-
fare.append(ui.pick("Select"), price);
|
|
339
|
-
box.append(fare);
|
|
340
|
-
const [ticked, noted] = terms(item);
|
|
341
|
-
box.append(renderInsights([...(item.reasons || []), ...ticked], noted));
|
|
342
|
-
return box;
|
|
206
|
+
const button = el("button", "btn" + (ui.chosen ? " chosen" : ""));
|
|
207
|
+
button.dataset.select = item.id;
|
|
208
|
+
button.title = label(item);
|
|
209
|
+
if (ui.chosen) button.append(icon("hotels/check-regular-full", "✓"));
|
|
210
|
+
button.append(el("span", "label", label(item)));
|
|
211
|
+
return button;
|
|
343
212
|
}
|
|
344
213
|
|
|
345
214
|
mount(root, card, {
|
|
346
215
|
empty: "No rental cars to show.",
|
|
216
|
+
bar: true,
|
|
347
217
|
option,
|
|
348
|
-
message: (item) => `
|
|
218
|
+
message: (item) => `Select car ${describe(item)}`,
|
|
349
219
|
});
|
|
350
220
|
}
|
|
351
221
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottotheagent/mcp-card-assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": [
|