@ottotheagent/mcp-card-assets 0.1.0 → 0.2.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 +12 -6
- package/flight-card.css +1 -0
- package/flight-card.js +221 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
**Created**: 2026-10-02 | **Last Updated**: 2026-10-02
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The Otto flight card renderer and airline logos. The `render_flights` 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
|
+
|
|
9
|
+
- `flight-card.js` draws the card and exposes `OttoFlightCard.render(rootElement, card)`. It carries its CSS inline, so the sandbox loads one script. Select sends the fare's `select_prompt` through the host's `sendPrompt()`.
|
|
10
|
+
- `flight-card.css` is the same CSS as a separate file, for reference.
|
|
11
|
+
- `marks/<IATA>.png` holds the airline logos.
|
|
8
12
|
|
|
9
|
-
The
|
|
13
|
+
The source is `src/flight-card.js`. `scripts/mcp/build_card_assets.py` writes the published files from it, from the card CSS in `server/mcp/apps/static/`, and from `airline-marks.json`. The generated files are not committed.
|
|
14
|
+
|
|
15
|
+
## Publish a new version
|
|
10
16
|
|
|
11
17
|
1. Bump `version` in `package.json`.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
18
|
+
2. Run `npm publish --access public` in this directory. The `prepack` step builds the files first.
|
|
19
|
+
3. Set `CARD_ASSETS_PACKAGE` in `server/mcp/apps/card_assets.py` to the same `name@version`. A test checks that the two match.
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
Publish before you deploy the server change. The server pins the version, and an unpublished version returns 404, which leaves the card empty.
|
package/flight-card.css
ADDED
|
@@ -0,0 +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;}.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);}}.carrier{display:flex;align-items:center;gap:var(--otto-space-3);min-height:var(--otto-chip-height);}.airline{flex:1;min-width:0;display:flex;align-items:center;gap:var(--otto-space-4);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);}.airlineName{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}.chips{display:flex;gap:var(--otto-space-1);flex-shrink:0;}.mark{width:var(--otto-mark-size);height:var(--otto-mark-size);border-radius:50%;flex-shrink:0;display:grid;place-items:center;font-size:var(--otto-text-xs);font-weight:var(--otto-weight-semibold);letter-spacing:0.3px;color:#fff;background:#5b6470;}img.mark{object-fit:contain;background:transparent;border:0;padding:0;}.fltline{display:flex;justify-content:space-between;margin-top:var(--otto-space-8);font-size:var(--otto-text-md);color:var(--otto-text-body);}.times,.ports{display:flex;align-items:center;justify-content:space-between;}.times{margin-top:var(--otto-space-2);gap:var(--otto-space-3);}.ports{margin-top:var(--otto-space-1);font-size:var(--otto-text-lg);color:var(--otto-text-body);}.clock{font-size:var(--otto-text-xl);font-weight:var(--otto-weight-semibold);white-space:nowrap;}.plus{font-size:var(--otto-text-sm);vertical-align:super;color:var(--otto-caution-text);}.rule{flex:1;height:1px;background:var(--otto-divider);}.plane{color:var(--otto-text-secondary);}.fareBlock{margin-top:var(--otto-space-6);display:flex;flex-direction:column;}.strip{height:66px;box-sizing:border-box;display:flex;align-items:flex-start;justify-content:space-between;gap:var(--otto-space-3);padding:9px var(--otto-space-5) 0;border:1px solid var(--otto-divider);border-radius:var(--otto-radius-md);background:var(--otto-strip-bg);font-size:var(--otto-text-base);}.strip + .fareRow{margin-top:-27px;}.lowest{min-width:0;font-weight:var(--otto-weight-semibold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.lowestName{font-weight:var(--otto-weight-regular);color:var(--otto-text-secondary);}.strip button.link{flex-shrink:0;display:inline-flex;gap:6px;height:auto;min-height:0;margin:0;padding:0;border:0;border-radius:0;box-shadow:none;background:none;appearance:none;cursor:pointer;align-items:center;font-size:var(--otto-text-base);line-height:var(--otto-line);color:var(--otto-text-primary);}@media (hover:hover){.opt.hasFares:hover .link{color:var(--otto-link-hover);}.opt.hasFares:hover .linkText{text-decoration:underline;}}.fareRow{position:relative;height:78px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:var(--otto-space-4);padding:var(--otto-space-2) var(--otto-space-5) var(--otto-space-2) var(--otto-space-6);border-radius:var(--otto-radius-fare);background:var(--otto-fare-bg);color:var(--otto-on-fare);}.fareText{min-width:0;}.amount{font-size:var(--otto-text-2xl);font-weight:var(--otto-weight-semibold);}.fareName{font-size:var(--otto-text-base);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.fareFacts{font-size:var(--otto-text-md);opacity:0.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.tag{font-size:var(--otto-text-md);}.fareRow .btn.onFare{padding:9px 20px;background:var(--otto-on-fare);color:var(--otto-fare-bg);border-color:var(--otto-on-fare);}.fareRow .btn.onFare.chosen{padding:9px var(--otto-space-7);border-color:var(--otto-accent);}.opt.hasFares{cursor:pointer;}.opt.open{border-color:var(--otto-accent);}.fareList{margin-top:var(--otto-space-7);padding:var(--otto-space-7);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);background:var(--otto-card-bg);display:flex;flex-direction:column;gap:var(--otto-space-3);}.fareListHead{margin-bottom:var(--otto-space-1);font-size:var(--otto-text-md);font-weight:var(--otto-weight-semibold);color:var(--otto-text-primary);}.ladderRow{display:flex;align-items:center;justify-content:space-between;gap:var(--otto-space-4);padding:var(--otto-space-3) var(--otto-space-3) var(--otto-space-3) var(--otto-space-6);border:1px solid var(--otto-divider);border-radius:var(--otto-radius-md);background:var(--otto-strip-bg);}.ladderRow.recommended{border-color:var(--otto-text-primary);}.ladderLabel{min-width:0;}.ladderName{font-size:var(--otto-text-base);font-weight:var(--otto-weight-semibold);color:var(--otto-text-primary);}.ladderFacts{font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.ladderRight{flex-shrink:0;display:flex;align-items:center;gap:var(--otto-space-5);}.ladderAmount{font-size:var(--otto-text-lg);font-weight:var(--otto-weight-semibold);}.ladderRow .btn{min-height:36px;padding:6px var(--otto-space-6);background:var(--otto-card-bg);border-color:var(--otto-text-primary);}.btn.chosen:not(.onFare){background:var(--otto-text-primary);color:var(--otto-card-bg);border-color:var(--otto-text-primary);}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}#root.busy .opt.open:not(.chosen){border-color:var(--otto-accent);}#root.busy .opt.hasFares:hover .link{color:var(--otto-text-primary);}#root.busy .opt.hasFares:hover .linkText{text-decoration:none;}.strip button.link:disabled{cursor:default;opacity:0.45;}.btn.chosen:disabled{opacity:1;}
|
package/flight-card.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/* Otto flight card renderer for host widget sandboxes (Claude's generative UI).
|
|
2
|
+
*
|
|
3
|
+
* The render_flights MCP tool returns `card`; the host's model pastes it into
|
|
4
|
+
* <div id="root"></div><script src=".../flight-card.js"></script>
|
|
5
|
+
* <script>OttoFlightCard.render(document.getElementById("root"), CARD)</script>
|
|
6
|
+
* The CSS is inlined at build time (scripts/mcp/build_card_assets.py), so the
|
|
7
|
+
* sandbox only has to allow this one script. Select sends the fare's
|
|
8
|
+
* `select_prompt` through the host's global sendPrompt().
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
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;}.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);}}.carrier{display:flex;align-items:center;gap:var(--otto-space-3);min-height:var(--otto-chip-height);}.airline{flex:1;min-width:0;display:flex;align-items:center;gap:var(--otto-space-4);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);}.airlineName{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}.chips{display:flex;gap:var(--otto-space-1);flex-shrink:0;}.mark{width:var(--otto-mark-size);height:var(--otto-mark-size);border-radius:50%;flex-shrink:0;display:grid;place-items:center;font-size:var(--otto-text-xs);font-weight:var(--otto-weight-semibold);letter-spacing:0.3px;color:#fff;background:#5b6470;}img.mark{object-fit:contain;background:transparent;border:0;padding:0;}.fltline{display:flex;justify-content:space-between;margin-top:var(--otto-space-8);font-size:var(--otto-text-md);color:var(--otto-text-body);}.times,.ports{display:flex;align-items:center;justify-content:space-between;}.times{margin-top:var(--otto-space-2);gap:var(--otto-space-3);}.ports{margin-top:var(--otto-space-1);font-size:var(--otto-text-lg);color:var(--otto-text-body);}.clock{font-size:var(--otto-text-xl);font-weight:var(--otto-weight-semibold);white-space:nowrap;}.plus{font-size:var(--otto-text-sm);vertical-align:super;color:var(--otto-caution-text);}.rule{flex:1;height:1px;background:var(--otto-divider);}.plane{color:var(--otto-text-secondary);}.fareBlock{margin-top:var(--otto-space-6);display:flex;flex-direction:column;}.strip{height:66px;box-sizing:border-box;display:flex;align-items:flex-start;justify-content:space-between;gap:var(--otto-space-3);padding:9px var(--otto-space-5) 0;border:1px solid var(--otto-divider);border-radius:var(--otto-radius-md);background:var(--otto-strip-bg);font-size:var(--otto-text-base);}.strip + .fareRow{margin-top:-27px;}.lowest{min-width:0;font-weight:var(--otto-weight-semibold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.lowestName{font-weight:var(--otto-weight-regular);color:var(--otto-text-secondary);}.strip button.link{flex-shrink:0;display:inline-flex;gap:6px;height:auto;min-height:0;margin:0;padding:0;border:0;border-radius:0;box-shadow:none;background:none;appearance:none;cursor:pointer;align-items:center;font-size:var(--otto-text-base);line-height:var(--otto-line);color:var(--otto-text-primary);}@media (hover:hover){.opt.hasFares:hover .link{color:var(--otto-link-hover);}.opt.hasFares:hover .linkText{text-decoration:underline;}}.fareRow{position:relative;height:78px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:var(--otto-space-4);padding:var(--otto-space-2) var(--otto-space-5) var(--otto-space-2) var(--otto-space-6);border-radius:var(--otto-radius-fare);background:var(--otto-fare-bg);color:var(--otto-on-fare);}.fareText{min-width:0;}.amount{font-size:var(--otto-text-2xl);font-weight:var(--otto-weight-semibold);}.fareName{font-size:var(--otto-text-base);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.fareFacts{font-size:var(--otto-text-md);opacity:0.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.tag{font-size:var(--otto-text-md);}.fareRow .btn.onFare{padding:9px 20px;background:var(--otto-on-fare);color:var(--otto-fare-bg);border-color:var(--otto-on-fare);}.fareRow .btn.onFare.chosen{padding:9px var(--otto-space-7);border-color:var(--otto-accent);}.opt.hasFares{cursor:pointer;}.opt.open{border-color:var(--otto-accent);}.fareList{margin-top:var(--otto-space-7);padding:var(--otto-space-7);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);background:var(--otto-card-bg);display:flex;flex-direction:column;gap:var(--otto-space-3);}.fareListHead{margin-bottom:var(--otto-space-1);font-size:var(--otto-text-md);font-weight:var(--otto-weight-semibold);color:var(--otto-text-primary);}.ladderRow{display:flex;align-items:center;justify-content:space-between;gap:var(--otto-space-4);padding:var(--otto-space-3) var(--otto-space-3) var(--otto-space-3) var(--otto-space-6);border:1px solid var(--otto-divider);border-radius:var(--otto-radius-md);background:var(--otto-strip-bg);}.ladderRow.recommended{border-color:var(--otto-text-primary);}.ladderLabel{min-width:0;}.ladderName{font-size:var(--otto-text-base);font-weight:var(--otto-weight-semibold);color:var(--otto-text-primary);}.ladderFacts{font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.ladderRight{flex-shrink:0;display:flex;align-items:center;gap:var(--otto-space-5);}.ladderAmount{font-size:var(--otto-text-lg);font-weight:var(--otto-weight-semibold);}.ladderRow .btn{min-height:36px;padding:6px var(--otto-space-6);background:var(--otto-card-bg);border-color:var(--otto-text-primary);}.btn.chosen:not(.onFare){background:var(--otto-text-primary);color:var(--otto-card-bg);border-color:var(--otto-text-primary);}#root.busy .opt{cursor:default;}#root.busy .opt:not(.chosen):hover{border-color:var(--otto-card-border);}#root.busy .opt.open:not(.chosen){border-color:var(--otto-accent);}#root.busy .opt.hasFares:hover .link{color:var(--otto-text-primary);}#root.busy .opt.hasFares:hover .linkText{text-decoration:none;}.strip button.link:disabled{cursor:default;opacity:0.45;}.btn.chosen:disabled{opacity:1;}";
|
|
14
|
+
const SYMBOLS = { USD: "$", EUR: "€", GBP: "£", JPY: "¥", CNY: "¥", AUD: "A$", CAD: "C$" };
|
|
15
|
+
// The widget cannot see when the host's reply ends, so the lock lifts on a timer.
|
|
16
|
+
const BUSY_MS = 60000;
|
|
17
|
+
|
|
18
|
+
function injectCss() {
|
|
19
|
+
if (!CSS || document.getElementById("otto-flight-card-css")) return;
|
|
20
|
+
const style = document.createElement("style");
|
|
21
|
+
style.id = "otto-flight-card-css";
|
|
22
|
+
style.textContent = CSS;
|
|
23
|
+
document.head.appendChild(style);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function el(tag, className, text) {
|
|
27
|
+
const node = document.createElement(tag);
|
|
28
|
+
if (className) node.className = className;
|
|
29
|
+
if (text !== undefined && text !== null) node.textContent = text;
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function render(root, card) {
|
|
34
|
+
injectCss();
|
|
35
|
+
let open = null;
|
|
36
|
+
let chosen = null;
|
|
37
|
+
let busy = false;
|
|
38
|
+
let timer = null;
|
|
39
|
+
|
|
40
|
+
const money = (value) =>
|
|
41
|
+
value === null || value === undefined
|
|
42
|
+
? ""
|
|
43
|
+
: (SYMBOLS[card.currency] || card.currency + " ") + Math.round(value).toLocaleString();
|
|
44
|
+
const duration = (m) => (m === null || m === undefined ? "" : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`);
|
|
45
|
+
const stops = (s) => (!s ? "" : s === 1 ? "1 stop" : `${s} stops`);
|
|
46
|
+
const recommended = (option) => option.fares.find((f) => f.recommended) || option.fares[0];
|
|
47
|
+
const allFares = () => card.items.flatMap((o) => o.fares || []);
|
|
48
|
+
|
|
49
|
+
// Logo from the CDN; the code circle in the airline's color when it does not load.
|
|
50
|
+
function mark(option) {
|
|
51
|
+
const circle = () => {
|
|
52
|
+
const node = el("div", "mark", option.airline || "");
|
|
53
|
+
if (option.mark_color) node.style.background = option.mark_color;
|
|
54
|
+
return node;
|
|
55
|
+
};
|
|
56
|
+
if (!option.mark_url) return circle();
|
|
57
|
+
const img = el("img", "mark");
|
|
58
|
+
img.alt = "";
|
|
59
|
+
img.src = option.mark_url;
|
|
60
|
+
img.onerror = () => img.replaceWith(circle());
|
|
61
|
+
return img;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function selectButton(className, fare) {
|
|
65
|
+
const picked = chosen === fare.id;
|
|
66
|
+
const button = el("button", className + (picked ? " chosen" : ""), picked ? "✓ Selected" : "Select");
|
|
67
|
+
button.dataset.select = fare.id;
|
|
68
|
+
return button;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function optionCard(option) {
|
|
72
|
+
const fares = option.fares || [];
|
|
73
|
+
const top = recommended(option);
|
|
74
|
+
const many = fares.length > 1;
|
|
75
|
+
const classes = ["opt"];
|
|
76
|
+
if (many) classes.push("hasFares");
|
|
77
|
+
if (open === option.id) classes.push("open");
|
|
78
|
+
if (fares.some((f) => f.id === chosen)) classes.push("chosen");
|
|
79
|
+
const box = el("div", classes.join(" "));
|
|
80
|
+
box.dataset.card = option.id;
|
|
81
|
+
|
|
82
|
+
const carrier = el("div", "carrier");
|
|
83
|
+
const airline = el("div", "airline");
|
|
84
|
+
airline.append(mark(option), el("span", "airlineName", option.airline_name || option.airline || ""));
|
|
85
|
+
carrier.append(airline);
|
|
86
|
+
const outOfPolicy = (option.badges || []).includes("out_of_policy");
|
|
87
|
+
if (outOfPolicy || option.callout) {
|
|
88
|
+
const chips = el("div", "chips");
|
|
89
|
+
chips.append(el("span", outOfPolicy ? "chip caution" : "chip", outOfPolicy ? "Out of policy" : option.callout));
|
|
90
|
+
carrier.append(chips);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fltline = el("div", "fltline");
|
|
94
|
+
fltline.append(
|
|
95
|
+
el("span", null, option.flight_number || ""),
|
|
96
|
+
el("span", null, [stops(option.stops), duration(option.duration_minutes)].filter(Boolean).join(" · ")),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const times = el("div", "times");
|
|
100
|
+
const arrive = el("span", "clock", option.arrive || "—");
|
|
101
|
+
if (option.arrives_next_day > 0) arrive.append(el("span", "plus", `+${option.arrives_next_day}`));
|
|
102
|
+
times.append(el("span", "clock", option.depart || "—"), el("span", "rule"), el("span", "plane", "✈"), el("span", "rule"), arrive);
|
|
103
|
+
|
|
104
|
+
const ports = el("div", "ports");
|
|
105
|
+
ports.append(el("span", null, option.origin || ""), el("span", null, option.destination || ""));
|
|
106
|
+
|
|
107
|
+
const fareBlock = el("div", "fareBlock");
|
|
108
|
+
if (many) {
|
|
109
|
+
const cheapest = fares.reduce((low, f) => (typeof f.price === "number" && (!low || f.price < low.price) ? f : low), null);
|
|
110
|
+
const strip = el("div", "strip");
|
|
111
|
+
const lowest = el("span", "lowest");
|
|
112
|
+
if (cheapest) lowest.append(`from ${money(cheapest.price)}`, el("span", "lowestName", ` for ${cheapest.name}`));
|
|
113
|
+
const link = el("button", "link");
|
|
114
|
+
link.dataset.fares = option.id;
|
|
115
|
+
link.append(el("span", "linkText", open === option.id ? "Hide fares" : "Other fares"), el("span", null, open === option.id ? "×" : "→"));
|
|
116
|
+
strip.append(lowest, link);
|
|
117
|
+
fareBlock.append(strip);
|
|
118
|
+
}
|
|
119
|
+
const row = el("div", "fareRow");
|
|
120
|
+
const text = el("div", "fareText");
|
|
121
|
+
text.append(el("div", "amount", money(top.price)), el("div", "fareName", top.name || " "));
|
|
122
|
+
text.append(many ? el("div", "tag", "☆ Recommended") : el("div", "fareFacts", (top.facts || []).join(" · ") || " "));
|
|
123
|
+
row.append(text, selectButton("btn onFare", top));
|
|
124
|
+
fareBlock.append(row);
|
|
125
|
+
|
|
126
|
+
box.append(carrier, fltline, times, ports, fareBlock);
|
|
127
|
+
if (option.reasons && option.reasons.length) {
|
|
128
|
+
const why = el("div", "why");
|
|
129
|
+
why.append(el("div", "whyHead", "Insights"));
|
|
130
|
+
for (const reason of option.reasons) {
|
|
131
|
+
const line = el("div", "whyLine");
|
|
132
|
+
line.append(el("span", "tick", "✓"), el("span", null, reason));
|
|
133
|
+
why.append(line);
|
|
134
|
+
}
|
|
135
|
+
box.append(why);
|
|
136
|
+
}
|
|
137
|
+
return box;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fareList() {
|
|
141
|
+
const option = card.items.find((o) => o.id === open);
|
|
142
|
+
if (!option) return null;
|
|
143
|
+
const box = el("div", "fareList");
|
|
144
|
+
box.append(el("div", "fareListHead", `Fares · ${[option.airline_name, option.flight_number].filter(Boolean).join(" · ")}`));
|
|
145
|
+
const sorted = [...(option.fares || [])].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
|
|
146
|
+
for (const fare of sorted) {
|
|
147
|
+
const row = el("div", "ladderRow" + (fare.recommended ? " recommended" : ""));
|
|
148
|
+
const label = el("div", "ladderLabel");
|
|
149
|
+
label.append(el("div", "ladderName", fare.name + (fare.recommended ? " · Recommended" : "")));
|
|
150
|
+
const facts = [...(fare.facts || []), fare.within_policy === false ? "Out of policy" : ""].filter(Boolean).join(" · ");
|
|
151
|
+
if (facts) label.append(el("div", "ladderFacts", facts));
|
|
152
|
+
const right = el("div", "ladderRight");
|
|
153
|
+
right.append(el("div", "ladderAmount", money(fare.price)), selectButton("btn", fare));
|
|
154
|
+
row.append(label, right);
|
|
155
|
+
box.append(row);
|
|
156
|
+
}
|
|
157
|
+
return box;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function draw() {
|
|
161
|
+
const scroll = root.querySelector(".row")?.scrollLeft || 0;
|
|
162
|
+
root.replaceChildren();
|
|
163
|
+
if (!card || !card.items || !card.items.length) {
|
|
164
|
+
root.append(el("p", "empty", "No flight options to show."));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const head = el("div", "head");
|
|
168
|
+
const label = card.price_label && card.price_label !== "total" ? `Prices are ${card.price_label}s` : "";
|
|
169
|
+
head.append(el("div", "title", card.title), el("div", "subtitle", [card.subtitle, label].filter(Boolean).join(" · ")));
|
|
170
|
+
const row = el("div", "row");
|
|
171
|
+
for (const option of card.items) row.append(optionCard(option));
|
|
172
|
+
root.append(head, row);
|
|
173
|
+
row.scrollLeft = scroll;
|
|
174
|
+
const list = fareList();
|
|
175
|
+
if (list) root.append(list);
|
|
176
|
+
root.classList.toggle("busy", busy);
|
|
177
|
+
root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function unlock() {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
busy = false;
|
|
183
|
+
draw();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// One pick at a time: the new fare replaces the old mark everywhere, then the card
|
|
187
|
+
// locks so a second click cannot send a second booking.
|
|
188
|
+
function select(fare) {
|
|
189
|
+
chosen = fare.id;
|
|
190
|
+
busy = true;
|
|
191
|
+
draw();
|
|
192
|
+
timer = setTimeout(unlock, BUSY_MS);
|
|
193
|
+
try {
|
|
194
|
+
const sent = window.sendPrompt(fare.select_prompt);
|
|
195
|
+
if (sent && typeof sent.catch === "function") sent.catch(unlock);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
unlock();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
root.addEventListener("click", (event) => {
|
|
202
|
+
if (busy) return;
|
|
203
|
+
const button = event.target.closest("[data-select]");
|
|
204
|
+
if (button) {
|
|
205
|
+
const fare = allFares().find((f) => f.id === button.dataset.select);
|
|
206
|
+
if (fare) select(fare);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (window.getSelection()?.toString()) return;
|
|
210
|
+
const opener = event.target.closest("[data-fares], .opt.hasFares");
|
|
211
|
+
if (!opener) return;
|
|
212
|
+
const id = opener.dataset.fares || opener.dataset.card;
|
|
213
|
+
open = open === id ? null : id;
|
|
214
|
+
draw();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
draw();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
window.OttoFlightCard = { render };
|
|
221
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottotheagent/mcp-card-assets",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Renderer, styles and airline logos for the Otto flight card that Claude draws in its widget sandbox.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"files": [
|
|
7
|
+
"flight-card.js",
|
|
8
|
+
"flight-card.css",
|
|
7
9
|
"marks/*.png",
|
|
8
10
|
"README.md"
|
|
9
11
|
],
|