@ottotheagent/mcp-card-assets 0.1.0 → 0.3.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 CHANGED
@@ -2,14 +2,20 @@
2
2
 
3
3
  **Created**: 2026-10-02 | **Last Updated**: 2026-10-02
4
4
 
5
- Airline logos for the Otto flight card. The `render_flights` MCP tool returns card data, and the host's model draws the card in its own widget sandbox. That sandbox loads images only from a few public CDNs, so the logos are served from jsDelivr: `https://cdn.jsdelivr.net/npm/@ottotheagent/mcp-card-assets@<version>/marks/<IATA>.png`.
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
- ## Publish a new version
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 builds "Book flight … (flight_id …)" from the fare and sends it 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 PNGs are generated from `server/mcp/apps/static/airline-marks.json` and are not committed.
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. Set `CARD_ASSETS_PACKAGE` in `server/mcp/apps/airline_marks.py` to the same `name@version`. A test checks that the two match.
13
- 3. Run `npm publish --access public` in this directory. `prepack` writes `marks/*.png` first.
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
- Published versions are immutable, so the server can pin one and let browsers cache it.
21
+ Publish before you deploy the server change. The server pins the version, and an unpublished version returns 404, which leaves the card empty.
@@ -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);}@media (hover:hover){.ladderRow:hover{border-color:var(--otto-accent);}}.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;}#root.busy .ladderRow:hover{border-color:var(--otto-divider);}#root.busy .ladderRow.recommended:hover{border-color:var(--otto-text-primary);}.strip button.link:disabled{cursor:default;opacity:0.45;}.btn.chosen:disabled{opacity:1;}
package/flight-card.js ADDED
@@ -0,0 +1,245 @@
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 "Book flight …
8
+ * (flight_id …)" 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);}@media (hover:hover){.ladderRow:hover{border-color:var(--otto-accent);}}.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;}#root.busy .ladderRow:hover{border-color:var(--otto-divider);}#root.busy .ladderRow.recommended:hover{border-color:var(--otto-text-primary);}.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
+ const optionOf = (fare) => card.items.find((o) => (o.fares || []).includes(fare));
49
+
50
+ // Supplier text flattened to one short line, quotes out, before it becomes the traveler's message.
51
+ const oneLine = (value, max = 60) =>
52
+ String(value ?? "")
53
+ .replace(/["“”‘’`]/g, "'")
54
+ .replace(/[\s\u0000-\u001f]+/g, " ")
55
+ .trim()
56
+ .slice(0, max);
57
+
58
+ // The pick in words, then the id the model books with.
59
+ function bookMessage(option, fare) {
60
+ const carrier = [option.airline_name, option.flight_number].filter(Boolean).join(" ");
61
+ const route = option.origin && option.destination ? `${oneLine(option.origin, 5)}→${oneLine(option.destination, 5)}` : "";
62
+ const parts = [
63
+ oneLine(carrier),
64
+ route,
65
+ option.depart ? `departing ${oneLine(option.depart, 12)}` : "",
66
+ oneLine(fare.name),
67
+ oneLine(fare.refundable),
68
+ money(fare.price),
69
+ ].filter(Boolean);
70
+ return `Book flight ${parts.join(", ")} (flight_id ${fare.id})`;
71
+ }
72
+
73
+ // Logo from the CDN; the code circle in the airline's color when it does not load.
74
+ function mark(option) {
75
+ const circle = () => {
76
+ const node = el("div", "mark", option.airline || "");
77
+ if (option.mark_color) node.style.background = option.mark_color;
78
+ return node;
79
+ };
80
+ if (!option.mark_url) return circle();
81
+ const img = el("img", "mark");
82
+ img.alt = "";
83
+ img.src = option.mark_url;
84
+ img.onerror = () => img.replaceWith(circle());
85
+ return img;
86
+ }
87
+
88
+ function selectButton(className, fare) {
89
+ const picked = chosen === fare.id;
90
+ const button = el("button", className + (picked ? " chosen" : ""), picked ? "✓ Selected" : "Select");
91
+ button.dataset.select = fare.id;
92
+ return button;
93
+ }
94
+
95
+ function optionCard(option) {
96
+ const fares = option.fares || [];
97
+ const top = recommended(option);
98
+ const many = fares.length > 1;
99
+ const classes = ["opt"];
100
+ if (many) classes.push("hasFares");
101
+ if (open === option.id) classes.push("open");
102
+ if (fares.some((f) => f.id === chosen)) classes.push("chosen");
103
+ const box = el("div", classes.join(" "));
104
+ box.dataset.card = option.id;
105
+
106
+ const carrier = el("div", "carrier");
107
+ const airline = el("div", "airline");
108
+ airline.append(mark(option), el("span", "airlineName", option.airline_name || option.airline || ""));
109
+ carrier.append(airline);
110
+ const outOfPolicy = (option.badges || []).includes("out_of_policy");
111
+ if (outOfPolicy || option.callout) {
112
+ const chips = el("div", "chips");
113
+ chips.append(el("span", outOfPolicy ? "chip caution" : "chip", outOfPolicy ? "Out of policy" : option.callout));
114
+ carrier.append(chips);
115
+ }
116
+
117
+ const fltline = el("div", "fltline");
118
+ fltline.append(
119
+ el("span", null, option.flight_number || ""),
120
+ el("span", null, [stops(option.stops), duration(option.duration_minutes)].filter(Boolean).join(" · ")),
121
+ );
122
+
123
+ const times = el("div", "times");
124
+ const arrive = el("span", "clock", option.arrive || "—");
125
+ if (option.arrives_next_day > 0) arrive.append(el("span", "plus", `+${option.arrives_next_day}`));
126
+ times.append(el("span", "clock", option.depart || "—"), el("span", "rule"), el("span", "plane", "✈"), el("span", "rule"), arrive);
127
+
128
+ const ports = el("div", "ports");
129
+ ports.append(el("span", null, option.origin || ""), el("span", null, option.destination || ""));
130
+
131
+ const fareBlock = el("div", "fareBlock");
132
+ if (many) {
133
+ const cheapest = fares.reduce((low, f) => (typeof f.price === "number" && (!low || f.price < low.price) ? f : low), null);
134
+ const strip = el("div", "strip");
135
+ const lowest = el("span", "lowest");
136
+ if (cheapest) lowest.append(`from ${money(cheapest.price)}`, el("span", "lowestName", ` for ${cheapest.name}`));
137
+ const link = el("button", "link");
138
+ link.dataset.fares = option.id;
139
+ link.append(el("span", "linkText", open === option.id ? "Hide fares" : "Other fares"), el("span", null, open === option.id ? "×" : "→"));
140
+ strip.append(lowest, link);
141
+ fareBlock.append(strip);
142
+ }
143
+ const row = el("div", "fareRow");
144
+ const text = el("div", "fareText");
145
+ text.append(el("div", "amount", money(top.price)), el("div", "fareName", top.name || " "));
146
+ text.append(many ? el("div", "tag", "☆ Recommended") : el("div", "fareFacts", (top.facts || []).join(" · ") || " "));
147
+ row.append(text, selectButton("btn onFare", top));
148
+ fareBlock.append(row);
149
+
150
+ box.append(carrier, fltline, times, ports, fareBlock);
151
+ if (option.reasons && option.reasons.length) {
152
+ const why = el("div", "why");
153
+ why.append(el("div", "whyHead", "Insights"));
154
+ for (const reason of option.reasons) {
155
+ const line = el("div", "whyLine");
156
+ line.append(el("span", "tick", "✓"), el("span", null, reason));
157
+ why.append(line);
158
+ }
159
+ box.append(why);
160
+ }
161
+ return box;
162
+ }
163
+
164
+ function fareList() {
165
+ const option = card.items.find((o) => o.id === open);
166
+ if (!option) return null;
167
+ const box = el("div", "fareList");
168
+ box.append(el("div", "fareListHead", `Fares · ${[option.airline_name, option.flight_number].filter(Boolean).join(" · ")}`));
169
+ const sorted = [...(option.fares || [])].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
170
+ for (const fare of sorted) {
171
+ const row = el("div", "ladderRow" + (fare.recommended ? " recommended" : ""));
172
+ const label = el("div", "ladderLabel");
173
+ label.append(el("div", "ladderName", fare.name + (fare.recommended ? " · Recommended" : "")));
174
+ const facts = [...(fare.facts || []), fare.within_policy === false ? "Out of policy" : ""].filter(Boolean).join(" · ");
175
+ if (facts) label.append(el("div", "ladderFacts", facts));
176
+ const right = el("div", "ladderRight");
177
+ right.append(el("div", "ladderAmount", money(fare.price)), selectButton("btn", fare));
178
+ row.append(label, right);
179
+ box.append(row);
180
+ }
181
+ return box;
182
+ }
183
+
184
+ function draw() {
185
+ const scroll = root.querySelector(".row")?.scrollLeft || 0;
186
+ root.replaceChildren();
187
+ if (!card || !card.items || !card.items.length) {
188
+ root.append(el("p", "empty", "No flight options to show."));
189
+ return;
190
+ }
191
+ const head = el("div", "head");
192
+ const label = card.price_label && card.price_label !== "total" ? `Prices are ${card.price_label}s` : "";
193
+ head.append(el("div", "title", card.title), el("div", "subtitle", [card.subtitle, label].filter(Boolean).join(" · ")));
194
+ const row = el("div", "row");
195
+ for (const option of card.items) row.append(optionCard(option));
196
+ root.append(head, row);
197
+ row.scrollLeft = scroll;
198
+ const list = fareList();
199
+ if (list) root.append(list);
200
+ root.classList.toggle("busy", busy);
201
+ root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
202
+ }
203
+
204
+ function unlock() {
205
+ clearTimeout(timer);
206
+ busy = false;
207
+ draw();
208
+ }
209
+
210
+ // One pick at a time: the new fare replaces the old mark everywhere, then the card
211
+ // locks so a second click cannot send a second booking.
212
+ function select(fare) {
213
+ chosen = fare.id;
214
+ busy = true;
215
+ draw();
216
+ timer = setTimeout(unlock, BUSY_MS);
217
+ try {
218
+ const sent = window.sendPrompt(bookMessage(optionOf(fare), fare));
219
+ if (sent && typeof sent.catch === "function") sent.catch(unlock);
220
+ } catch (error) {
221
+ unlock();
222
+ }
223
+ }
224
+
225
+ root.addEventListener("click", (event) => {
226
+ if (busy) return;
227
+ const button = event.target.closest("[data-select]");
228
+ if (button) {
229
+ const fare = allFares().find((f) => f.id === button.dataset.select);
230
+ if (fare) select(fare);
231
+ return;
232
+ }
233
+ if (window.getSelection()?.toString()) return;
234
+ const opener = event.target.closest("[data-fares], .opt.hasFares");
235
+ if (!opener) return;
236
+ const id = opener.dataset.fares || opener.dataset.card;
237
+ open = open === id ? null : id;
238
+ draw();
239
+ });
240
+
241
+ draw();
242
+ }
243
+
244
+ window.OttoFlightCard = { render };
245
+ })();
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@ottotheagent/mcp-card-assets",
3
- "version": "0.1.0",
4
- "description": "Static assets (airline logos) for the Otto flight card that Claude draws in its widget sandbox.",
3
+ "version": "0.3.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
  ],