@ottotheagent/mcp-card-assets 0.11.0 → 0.13.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 +3 -2
- package/flight-card.css +1 -1
- package/flight-card.js +16 -2
- package/package.json +4 -2
- package/seat-map.css +1 -0
- package/seat-map.js +364 -0
package/README.md
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
**Created**: 2026-10-02 | **Last Updated**: 2026-10-06
|
|
4
4
|
|
|
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.
|
|
5
|
+
Renderers, logos and photo placeholders for the Otto flight, hotel, room and car cards and the seat map. Each `render_*` MCP tool returns card data and a snippet. The host's model mounts the card in its own widget sandbox, which loads files only from a few public CDNs, so the assets are served from jsDelivr.
|
|
6
6
|
|
|
7
7
|
## Contents
|
|
8
8
|
|
|
9
9
|
- `flight-card.js`, `hotel-card.js`, `room-card.js` and `car-card.js` each draw one card and expose `OttoFlightCard`, `OttoHotelCard`, `OttoRoomCard` or `OttoCarCard` with `render(rootElement, card)`. Each carries its own CSS and only the icons it uses inline, so the sandbox loads one script per card. The server's snippet loads jsDelivr's minified copy (`*-card.min.js`). 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
|
+
- `seat-map.js` exposes `OttoSeatMap.render(rootElement, card)` (since 0.12.0). It draws one flight's cabin from `render_elastic_seat_map`. The server lays out the columns, aisles and rows for the aircraft and cabin, so the script only draws them. Clicking a seat marks it New selection without sending anything; since 0.13.0 the **Update seat to 15D** button then sends "Update seat to 15D (route)".
|
|
10
11
|
- 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
12
|
- `*-card.css` holds each card's CSS as a separate file, for reference.
|
|
12
13
|
- `marks/<IATA>.png` holds airline logos, and `marks/cars/<code>.svg` holds car-company logos.
|
|
13
14
|
- `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
15
|
|
|
15
|
-
The sources are `src/flight-card.js`, and `src/core.js` plus `src/<kind>-card.js` for the
|
|
16
|
+
The sources are `src/flight-card.js`, and `src/core.js` plus `src/<kind>-card.js` (or `src/seat-map.js`) for the other renderers. `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
17
|
|
|
17
18
|
## Publish a new version
|
|
18
19
|
|
package/flight-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;}.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
|
|
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 .link{flex-shrink:0;display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:var(--otto-text-base);line-height:var(--otto-line);color:var(--otto-text-primary);user-select:none;}.strip .link:focus-visible{outline:2px solid var(--otto-accent);outline-offset:2px;border-radius:2px;}@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);}#root.busy .strip .link{cursor:default;opacity:0.45;}.btn.chosen:disabled{opacity:1;}
|
package/flight-card.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
(function () {
|
|
11
11
|
"use strict";
|
|
12
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
|
|
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 .link{flex-shrink:0;display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:var(--otto-text-base);line-height:var(--otto-line);color:var(--otto-text-primary);user-select:none;}.strip .link:focus-visible{outline:2px solid var(--otto-accent);outline-offset:2px;border-radius:2px;}@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);}#root.busy .strip .link{cursor:default;opacity:0.45;}.btn.chosen:disabled{opacity:1;}";
|
|
14
14
|
const SYMBOLS = { USD: "$", EUR: "€", GBP: "£", JPY: "¥", CNY: "¥", AUD: "A$", CAD: "C$" };
|
|
15
15
|
// The widget cannot see when the host's reply ends, so the lock lifts on a timer.
|
|
16
16
|
const BUSY_MS = 60000;
|
|
@@ -143,7 +143,12 @@
|
|
|
143
143
|
const strip = el("div", "strip");
|
|
144
144
|
const lowest = el("span", "lowest");
|
|
145
145
|
if (cheapest) lowest.append(`from ${money(cheapest.price)}`, el("span", "lowestName", ` for ${cheapest.name}`));
|
|
146
|
-
|
|
146
|
+
// A span, not a <button>: host sandboxes restyle every button (border, padding,
|
|
147
|
+
// min-height), which boxed the link and pushed it off the "from $…" line.
|
|
148
|
+
const link = el("span", "link");
|
|
149
|
+
link.setAttribute("role", "button");
|
|
150
|
+
link.tabIndex = 0;
|
|
151
|
+
link.setAttribute("aria-expanded", String(open === option.id));
|
|
147
152
|
link.dataset.fares = option.id;
|
|
148
153
|
link.append(el("span", "linkText", open === option.id ? "Hide fares" : "Other fares"), el("span", null, open === option.id ? "×" : "→"));
|
|
149
154
|
strip.append(lowest, link);
|
|
@@ -208,6 +213,7 @@
|
|
|
208
213
|
if (list) root.append(list);
|
|
209
214
|
root.classList.toggle("busy", busy);
|
|
210
215
|
root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
|
|
216
|
+
root.querySelectorAll(".link").forEach((l) => l.setAttribute("aria-disabled", String(busy)));
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
function unlock() {
|
|
@@ -248,6 +254,14 @@
|
|
|
248
254
|
draw();
|
|
249
255
|
};
|
|
250
256
|
|
|
257
|
+
// The fare link is a span, so Enter and Space open it the way they would a button.
|
|
258
|
+
root.onkeydown = (event) => {
|
|
259
|
+
const link = event.target.closest?.(".link[data-fares]");
|
|
260
|
+
if (!link || (event.key !== "Enter" && event.key !== " ")) return;
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
link.click();
|
|
263
|
+
};
|
|
264
|
+
|
|
251
265
|
draw();
|
|
252
266
|
const handle = { cancel: () => clearTimeout(timer) };
|
|
253
267
|
root.__ottoCard = handle;
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottotheagent/mcp-card-assets",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Renderers, styles, logos and photo placeholders for the Otto flight, hotel, room and car cards that Claude draws in its widget sandbox.",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Renderers, styles, logos and photo placeholders for the Otto flight, hotel, room and car cards and the seat map that Claude draws in its widget sandbox.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"files": [
|
|
7
7
|
"*-card.js",
|
|
8
8
|
"*-card.css",
|
|
9
|
+
"seat-map.js",
|
|
10
|
+
"seat-map.css",
|
|
9
11
|
"marks/*.png",
|
|
10
12
|
"marks/cars/*.svg",
|
|
11
13
|
"placeholders/*.svg",
|
package/seat-map.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;}.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);}}.sm{--sm-seat:28px;--sm-gap:4px;--sm-aisle:16px;box-sizing:border-box;max-width:100%;padding:var(--otto-space-7);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);background:var(--otto-card-bg);color:var(--otto-text-primary);}.sm.roomy{--sm-seat:34px;}.sm.suite{--sm-seat:40px;--sm-gap:6px;--sm-aisle:22px;}.smHead{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--otto-space-5);margin-bottom:var(--otto-space-5);}.smHead .title{font-size:var(--otto-text-lg);font-weight:var(--otto-weight-semibold);}.smHead .subtitle{font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.smToggle{min-height:32px;padding:4px var(--otto-space-5);font-size:var(--otto-text-md);}.smScroll{overflow-x:auto;}.smGrid{display:inline-flex;flex-direction:column;gap:var(--otto-space-2);}.smRow{display:flex;align-items:center;gap:var(--sm-gap);}.smNum{width:28px;flex-shrink:0;font-size:var(--otto-text-md);color:var(--otto-text-secondary);font-variant-numeric:tabular-nums;}.smNum.exit{color:var(--otto-caution-text);font-weight:var(--otto-weight-semibold);}.smAisle{width:var(--sm-aisle);flex-shrink:0;}.smLetter,.smSeat,.smEmpty{width:var(--sm-seat);height:var(--sm-seat);flex-shrink:0;}.smLetter{height:auto;text-align:center;font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.smSeat{box-sizing:border-box;display:grid;place-items:center;padding:0;border:1px solid var(--otto-card-border);border-radius:6px;background:var(--otto-card-bg);color:var(--otto-text-body);font-size:var(--otto-text-md);font-weight:var(--otto-weight-medium);line-height:1;}.smSeat[role="button"]{cursor:pointer;user-select:none;}.smSeat[role="button"]:hover{border-color:var(--otto-accent);}.smSeat[role="button"]:focus-visible{outline:2px solid var(--otto-accent);outline-offset:1px;}.smSeat.paid{background:var(--otto-caution-bg);}.smSeat.legroom{border-top-width:3px;}.smSeat.off{border-color:var(--otto-divider);background:var(--otto-strip-bg);color:var(--otto-text-secondary);cursor:default;}.smSeat.mine{border-color:var(--otto-accent);background:var(--otto-accent);color:#0a0a0a;font-weight:var(--otto-weight-semibold);}.smSeat.pick{border:2px solid var(--otto-accent);background:var(--otto-chip-bg);color:var(--otto-chip-text);font-weight:var(--otto-weight-semibold);}.smLegend{display:flex;flex-wrap:wrap;gap:var(--otto-space-2) var(--otto-space-5);margin-top:var(--otto-space-5);font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.smKey{display:inline-flex;align-items:center;gap:var(--otto-space-2);}.smKey .smSeat{--sm-seat:16px;font-size:9px;border-radius:4px;}.smFoot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--otto-space-5);margin-top:var(--otto-space-5);font-size:var(--otto-text-base);}.smFoot .btn{border:1px solid var(--otto-btn-border);color:var(--otto-text-primary);}.smFoot .btn:hover:not(:disabled){border-color:var(--otto-accent);}.sm.busy button,.sm.busy .smSeat{cursor:default;}
|
package/seat-map.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
/* Shared core for the hotel, room and car renderers. The build wraps it with one card's
|
|
4
|
+
* source in a single script, and inlines that card's CSS and only the icons it uses.
|
|
5
|
+
*
|
|
6
|
+
* A card source calls mount(root, card, spec):
|
|
7
|
+
* spec.option(item, ui) builds one option's .opt element; ui.chosen says whether it is
|
|
8
|
+
* the current pick and ui.pick(label) makes its Select button.
|
|
9
|
+
* spec.message(item) is the text Select sends through the host's sendPrompt().
|
|
10
|
+
* spec.empty is the line shown when the card has no items.
|
|
11
|
+
* spec.bar draws the options as one wrapping row of buttons with no title, for a card
|
|
12
|
+
* whose details the host already shows above it.
|
|
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);}}.sm{--sm-seat:28px;--sm-gap:4px;--sm-aisle:16px;box-sizing:border-box;max-width:100%;padding:var(--otto-space-7);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);background:var(--otto-card-bg);color:var(--otto-text-primary);}.sm.roomy{--sm-seat:34px;}.sm.suite{--sm-seat:40px;--sm-gap:6px;--sm-aisle:22px;}.smHead{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--otto-space-5);margin-bottom:var(--otto-space-5);}.smHead .title{font-size:var(--otto-text-lg);font-weight:var(--otto-weight-semibold);}.smHead .subtitle{font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.smToggle{min-height:32px;padding:4px var(--otto-space-5);font-size:var(--otto-text-md);}.smScroll{overflow-x:auto;}.smGrid{display:inline-flex;flex-direction:column;gap:var(--otto-space-2);}.smRow{display:flex;align-items:center;gap:var(--sm-gap);}.smNum{width:28px;flex-shrink:0;font-size:var(--otto-text-md);color:var(--otto-text-secondary);font-variant-numeric:tabular-nums;}.smNum.exit{color:var(--otto-caution-text);font-weight:var(--otto-weight-semibold);}.smAisle{width:var(--sm-aisle);flex-shrink:0;}.smLetter,.smSeat,.smEmpty{width:var(--sm-seat);height:var(--sm-seat);flex-shrink:0;}.smLetter{height:auto;text-align:center;font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.smSeat{box-sizing:border-box;display:grid;place-items:center;padding:0;border:1px solid var(--otto-card-border);border-radius:6px;background:var(--otto-card-bg);color:var(--otto-text-body);font-size:var(--otto-text-md);font-weight:var(--otto-weight-medium);line-height:1;}.smSeat[role=\"button\"]{cursor:pointer;user-select:none;}.smSeat[role=\"button\"]:hover{border-color:var(--otto-accent);}.smSeat[role=\"button\"]:focus-visible{outline:2px solid var(--otto-accent);outline-offset:1px;}.smSeat.paid{background:var(--otto-caution-bg);}.smSeat.legroom{border-top-width:3px;}.smSeat.off{border-color:var(--otto-divider);background:var(--otto-strip-bg);color:var(--otto-text-secondary);cursor:default;}.smSeat.mine{border-color:var(--otto-accent);background:var(--otto-accent);color:#0a0a0a;font-weight:var(--otto-weight-semibold);}.smSeat.pick{border:2px solid var(--otto-accent);background:var(--otto-chip-bg);color:var(--otto-chip-text);font-weight:var(--otto-weight-semibold);}.smLegend{display:flex;flex-wrap:wrap;gap:var(--otto-space-2) var(--otto-space-5);margin-top:var(--otto-space-5);font-size:var(--otto-text-md);color:var(--otto-text-secondary);}.smKey{display:inline-flex;align-items:center;gap:var(--otto-space-2);}.smKey .smSeat{--sm-seat:16px;font-size:9px;border-radius:4px;}.smFoot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--otto-space-5);margin-top:var(--otto-space-5);font-size:var(--otto-text-base);}.smFoot .btn{border:1px solid var(--otto-btn-border);color:var(--otto-text-primary);}.smFoot .btn:hover:not(:disabled){border-color:var(--otto-accent);}.sm.busy button,.sm.busy .smSeat{cursor:default;}";
|
|
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
|
+
const SYMBOLS = { USD: "$", EUR: "€", GBP: "£", JPY: "¥", CNY: "¥", AUD: "A$", CAD: "C$" };
|
|
17
|
+
// The widget cannot see when the host's reply ends, so the lock lifts on a timer.
|
|
18
|
+
const BUSY_MS = 60000;
|
|
19
|
+
const CHECK_ICON = "hotels/check-regular-full";
|
|
20
|
+
const INFO_ICON = "hotels/circle-info-regular-full";
|
|
21
|
+
// Placeholders ship beside this script, so they always match its package version.
|
|
22
|
+
const ASSET_BASE = ((document.currentScript && document.currentScript.src) || "").replace(/[^/]*$/, "");
|
|
23
|
+
|
|
24
|
+
function injectCss(id) {
|
|
25
|
+
if (!CSS || document.getElementById(id)) return;
|
|
26
|
+
const style = document.createElement("style");
|
|
27
|
+
style.id = id;
|
|
28
|
+
style.textContent = CSS;
|
|
29
|
+
document.head.appendChild(style);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function el(tag, className, text) {
|
|
33
|
+
const node = document.createElement(tag);
|
|
34
|
+
if (className) node.className = className;
|
|
35
|
+
if (text !== undefined && text !== null && text !== "") node.textContent = String(text);
|
|
36
|
+
return node;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function icon(names, fallback, className = "icon") {
|
|
40
|
+
const node = el("span", className);
|
|
41
|
+
node.setAttribute("aria-hidden", "true");
|
|
42
|
+
const markup = [names].flat().map((name) => ICONS[name]).find(Boolean);
|
|
43
|
+
if (markup) node.innerHTML = markup;
|
|
44
|
+
else node.textContent = fallback ?? "";
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The pick message quotes the exact price, so the model can tell apart options that round alike.
|
|
49
|
+
function formatExactMoney(value, currency) {
|
|
50
|
+
if (value === null || value === undefined) return "";
|
|
51
|
+
const symbol = SYMBOLS[currency];
|
|
52
|
+
const fraction = Number.isInteger(value) ? 0 : 2;
|
|
53
|
+
const amount = value.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: 2 });
|
|
54
|
+
return symbol ? `${symbol}${amount}` : `${currency} ${amount}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatMoney(value, currency) {
|
|
58
|
+
if (value === null || value === undefined) return "";
|
|
59
|
+
const symbol = SYMBOLS[currency];
|
|
60
|
+
const amount = Math.round(value).toLocaleString();
|
|
61
|
+
return symbol ? `${symbol}${amount}` : `${currency} ${amount}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Supplier text flattened to one short line, quotes out, before it becomes the traveler's message.
|
|
65
|
+
function oneLine(value, max = 60) {
|
|
66
|
+
return String(value ?? "")
|
|
67
|
+
.replace(/["“”‘’`]/g, "'")
|
|
68
|
+
.replace(/[\s\u0000-\u001f]+/g, " ")
|
|
69
|
+
.trim()
|
|
70
|
+
.slice(0, max);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The photo plate's stand-in: hotel, room or car. Supplier photos cannot load in host sandboxes.
|
|
74
|
+
function placeholder(kind) {
|
|
75
|
+
const img = el("img");
|
|
76
|
+
img.src = `${ASSET_BASE}placeholders/${kind}.svg`;
|
|
77
|
+
img.alt = "";
|
|
78
|
+
img.addEventListener("error", () => img.remove());
|
|
79
|
+
return img;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The caller's reasons as ticked "Insights" lines, then caveats under the info mark.
|
|
83
|
+
function renderInsights(reasons, notes) {
|
|
84
|
+
if (!reasons?.length && !notes?.length) return null;
|
|
85
|
+
const why = el("div", "why");
|
|
86
|
+
why.append(el("div", "whyHead", "Insights"));
|
|
87
|
+
for (const [texts, className, names, glyph] of [
|
|
88
|
+
[reasons, "icon tick", CHECK_ICON, "✓"],
|
|
89
|
+
[notes, "icon tick note", INFO_ICON, "ⓘ"],
|
|
90
|
+
]) {
|
|
91
|
+
for (const text of texts ?? []) {
|
|
92
|
+
const line = el("div", "whyLine");
|
|
93
|
+
line.append(icon(names, glyph, className), el("span", null, text));
|
|
94
|
+
why.append(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return why;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mount(root, card, spec) {
|
|
101
|
+
// A second mount on the same root replaces the first card; its lock timer must not
|
|
102
|
+
// fire later and redraw over the new card.
|
|
103
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
104
|
+
let chosen = null;
|
|
105
|
+
let busy = false;
|
|
106
|
+
let timer = null;
|
|
107
|
+
|
|
108
|
+
function draw() {
|
|
109
|
+
const scroll = root.querySelector(".row")?.scrollLeft || 0;
|
|
110
|
+
root.replaceChildren();
|
|
111
|
+
if (!card || !card.items || !card.items.length) {
|
|
112
|
+
root.append(el("p", "empty", spec.empty));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const row = el("div", spec.bar ? "bar" : "row");
|
|
116
|
+
for (const item of card.items) {
|
|
117
|
+
const ui = {
|
|
118
|
+
chosen: chosen === item.id,
|
|
119
|
+
pick(label) {
|
|
120
|
+
const button = el("button", "btn" + (chosen === item.id ? " chosen" : ""));
|
|
121
|
+
button.dataset.select = item.id;
|
|
122
|
+
if (chosen === item.id) button.append(icon(CHECK_ICON, "✓"), document.createTextNode("Selected"));
|
|
123
|
+
else button.textContent = label;
|
|
124
|
+
return button;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
row.append(spec.option(item, ui));
|
|
128
|
+
}
|
|
129
|
+
if (!spec.bar) {
|
|
130
|
+
const head = el("div", "head");
|
|
131
|
+
head.append(el("div", "title", card.title), el("div", "subtitle", card.subtitle));
|
|
132
|
+
root.append(head);
|
|
133
|
+
}
|
|
134
|
+
root.append(row);
|
|
135
|
+
row.scrollLeft = scroll;
|
|
136
|
+
root.classList.toggle("busy", busy);
|
|
137
|
+
root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function unlock() {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
busy = false;
|
|
143
|
+
draw();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// One pick at a time: the new pick replaces the old mark, then the card locks so a
|
|
147
|
+
// second click cannot send a second message.
|
|
148
|
+
// Assigned, not added: a re-mount replaces the handler instead of stacking another.
|
|
149
|
+
root.onclick = (event) => {
|
|
150
|
+
if (busy) return;
|
|
151
|
+
const button = event.target.closest("[data-select]");
|
|
152
|
+
const item = button && card.items.find((i) => i.id === button.dataset.select);
|
|
153
|
+
if (!item) return;
|
|
154
|
+
chosen = item.id;
|
|
155
|
+
busy = true;
|
|
156
|
+
draw();
|
|
157
|
+
timer = setTimeout(unlock, BUSY_MS);
|
|
158
|
+
try {
|
|
159
|
+
const sent = window.sendPrompt(spec.message(item));
|
|
160
|
+
if (sent && typeof sent.catch === "function") sent.catch(unlock);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
unlock();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
draw();
|
|
167
|
+
const handle = { cancel: () => clearTimeout(timer) };
|
|
168
|
+
root.__ottoCard = handle;
|
|
169
|
+
return handle;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Otto seat map: OttoSeatMap.render(root, card), card from render_elastic_seat_map.
|
|
173
|
+
* The server lays the cabin out (columns, aisles, rows aligned to columns), so any aircraft
|
|
174
|
+
* and cabin draws the same way. Collapsed, it shows the traveler's row and one row either
|
|
175
|
+
* side; Expand shows the whole cabin. Clicking another seat only marks it New selection and
|
|
176
|
+
* shows "Update seat to 15D"; that button sends "Update seat to 15D (SEA → JFK)" as the
|
|
177
|
+
* traveler's message. */
|
|
178
|
+
|
|
179
|
+
function render(root, card) {
|
|
180
|
+
injectCss("otto-seat-map-css");
|
|
181
|
+
if (root.__ottoCard) root.__ottoCard.cancel();
|
|
182
|
+
if (!card || !card.rows || !card.rows.length) {
|
|
183
|
+
root.replaceChildren(el("p", "empty", "No seat map to show."));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const seats = new Map();
|
|
188
|
+
card.rows.forEach((row, index) => row.seats.forEach((seat) => seat && seats.set(seat.id, { seat, index })));
|
|
189
|
+
const home = seats.get(card.current_seat)?.index ?? 0;
|
|
190
|
+
const aisles = new Set(card.aisles_after || []);
|
|
191
|
+
let expanded = false;
|
|
192
|
+
let pick = null;
|
|
193
|
+
let busy = false;
|
|
194
|
+
let timer = null;
|
|
195
|
+
|
|
196
|
+
const letter = (id) => String(id).replace(/^\d+/, "");
|
|
197
|
+
const cost = (seat) => (seat.price ? formatExactMoney(seat.price, card.currency || "USD") : "free");
|
|
198
|
+
const describe = (seat) => [seat.id, seat.type, cost(seat)].filter(Boolean).join(" · ");
|
|
199
|
+
|
|
200
|
+
function seatClass(seat) {
|
|
201
|
+
if (seat.id === card.current_seat) return "mine";
|
|
202
|
+
if (seat.id === pick) return "pick";
|
|
203
|
+
return { free: "free", paid: "paid", unavailable: "off" }[seat.state] || "off";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function seatNode(seat) {
|
|
207
|
+
const cls = seatClass(seat);
|
|
208
|
+
const legroom = seat.extra_legroom && cls !== "off" ? " legroom" : "";
|
|
209
|
+
if (cls === "off") {
|
|
210
|
+
const node = el("span", "smSeat off", "×");
|
|
211
|
+
node.setAttribute("role", "img");
|
|
212
|
+
node.setAttribute("aria-label", `${seat.id} unavailable`);
|
|
213
|
+
node.title = `${seat.id} · unavailable`;
|
|
214
|
+
return node;
|
|
215
|
+
}
|
|
216
|
+
// A span, not a <button>: host sandboxes restyle every button (background, border,
|
|
217
|
+
// padding), which hid the seat states and pushed the letter off center.
|
|
218
|
+
const button = el("span", `smSeat ${cls}${legroom}`, letter(seat.id));
|
|
219
|
+
button.setAttribute("role", "button");
|
|
220
|
+
button.tabIndex = busy ? -1 : 0;
|
|
221
|
+
button.dataset.seat = seat.id;
|
|
222
|
+
button.title =
|
|
223
|
+
describe(seat) + (seat.extra_legroom ? " · extra legroom" : "") + (cls === "pick" ? " · new selection" : "");
|
|
224
|
+
button.setAttribute("aria-label", button.title);
|
|
225
|
+
button.setAttribute("aria-pressed", String(cls === "mine" || cls === "pick"));
|
|
226
|
+
return button;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function lettersRow() {
|
|
230
|
+
const line = el("div", "smRow");
|
|
231
|
+
line.append(el("span", "smNum"));
|
|
232
|
+
card.columns.forEach((name, i) => {
|
|
233
|
+
line.append(el("span", "smLetter", name));
|
|
234
|
+
if (aisles.has(i)) line.append(el("span", "smAisle"));
|
|
235
|
+
});
|
|
236
|
+
return line;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function seatRow(row) {
|
|
240
|
+
const line = el("div", "smRow");
|
|
241
|
+
const number = el("span", "smNum" + (row.exit ? " exit" : ""), row.number);
|
|
242
|
+
if (row.exit) number.title = "Exit row";
|
|
243
|
+
line.append(number);
|
|
244
|
+
row.seats.forEach((seat, i) => {
|
|
245
|
+
line.append(seat ? seatNode(seat) : el("span", "smEmpty"));
|
|
246
|
+
if (aisles.has(i)) line.append(el("span", "smAisle"));
|
|
247
|
+
});
|
|
248
|
+
return line;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Swatches reuse the seat classes, so the legend shows exactly what the map draws.
|
|
252
|
+
function legend() {
|
|
253
|
+
const states = new Set([...seats.values()].map(({ seat }) => seat.state));
|
|
254
|
+
const keys = [
|
|
255
|
+
["mine", "Your seat", true],
|
|
256
|
+
["pick", "New selection", true],
|
|
257
|
+
["free", "Free", states.has("free")],
|
|
258
|
+
["paid", "Paid", states.has("paid")],
|
|
259
|
+
["off", "Unavailable", states.has("unavailable")],
|
|
260
|
+
];
|
|
261
|
+
const box = el("div", "smLegend");
|
|
262
|
+
for (const [cls, label, shown] of keys) {
|
|
263
|
+
if (!shown) continue;
|
|
264
|
+
const key = el("span", "smKey");
|
|
265
|
+
const swatch = el("span", `smSeat ${cls}`, cls === "off" ? "×" : "");
|
|
266
|
+
swatch.setAttribute("aria-hidden", "true");
|
|
267
|
+
key.append(swatch, el("span", null, label));
|
|
268
|
+
box.append(key);
|
|
269
|
+
}
|
|
270
|
+
if ([...seats.values()].some(({ seat }) => seat.extra_legroom)) {
|
|
271
|
+
const key = el("span", "smKey");
|
|
272
|
+
key.append(el("span", "smSeat free legroom"), el("span", null, "Extra legroom"));
|
|
273
|
+
box.append(key);
|
|
274
|
+
}
|
|
275
|
+
return box;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function draw() {
|
|
279
|
+
const scroll = root.querySelector(".smScroll")?.scrollLeft || 0;
|
|
280
|
+
const box = el("div", `sm ${card.size || "standard"}${busy ? " busy" : ""}`);
|
|
281
|
+
|
|
282
|
+
const head = el("div", "smHead");
|
|
283
|
+
const text = el("div");
|
|
284
|
+
text.append(el("div", "title", card.title), el("div", "subtitle", card.subtitle));
|
|
285
|
+
head.append(text);
|
|
286
|
+
if (card.rows.length > 3) {
|
|
287
|
+
const toggle = el("button", "btn smToggle", expanded ? "Collapse" : "Expand");
|
|
288
|
+
toggle.type = "button";
|
|
289
|
+
toggle.dataset.toggle = "1";
|
|
290
|
+
toggle.setAttribute("aria-expanded", String(expanded));
|
|
291
|
+
head.append(toggle);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rows = expanded ? card.rows : card.rows.slice(Math.max(0, home - 1), home + 2);
|
|
295
|
+
const grid = el("div", "smGrid");
|
|
296
|
+
grid.append(lettersRow(), ...rows.map(seatRow));
|
|
297
|
+
const scroller = el("div", "smScroll");
|
|
298
|
+
scroller.append(grid);
|
|
299
|
+
|
|
300
|
+
const foot = el("div", "smFoot");
|
|
301
|
+
const mine = seats.get(card.current_seat)?.seat;
|
|
302
|
+
const chosen = pick && seats.get(pick)?.seat;
|
|
303
|
+
foot.append(el("span", null, chosen ? `New selection: ${describe(chosen)}` : mine ? `Your seat: ${describe(mine)}` : ""));
|
|
304
|
+
if (chosen) {
|
|
305
|
+
const update = el("button", "btn", `Update seat to ${chosen.id}`);
|
|
306
|
+
update.type = "button";
|
|
307
|
+
update.dataset.update = chosen.id;
|
|
308
|
+
foot.append(update);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
box.append(head, scroller, legend(), foot);
|
|
312
|
+
root.replaceChildren(box);
|
|
313
|
+
scroller.scrollLeft = scroll;
|
|
314
|
+
root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unlock() {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
busy = false;
|
|
320
|
+
draw();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Assigned, not added: a re-mount replaces the handler instead of stacking another.
|
|
324
|
+
root.onclick = (event) => {
|
|
325
|
+
if (busy) return;
|
|
326
|
+
const target = event.target.closest("button, [data-seat]");
|
|
327
|
+
if (!target) return;
|
|
328
|
+
if (target.dataset.toggle) {
|
|
329
|
+
expanded = !expanded;
|
|
330
|
+
} else if (target.dataset.seat) {
|
|
331
|
+
const id = target.dataset.seat;
|
|
332
|
+
pick = id === card.current_seat || id === pick ? null : id;
|
|
333
|
+
} else if (target.dataset.update) {
|
|
334
|
+
busy = true;
|
|
335
|
+
timer = setTimeout(unlock, BUSY_MS);
|
|
336
|
+
try {
|
|
337
|
+
const sent = window.sendPrompt(`Update seat to ${target.dataset.update} (${card.route})`);
|
|
338
|
+
if (sent && typeof sent.catch === "function") sent.catch(unlock);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
unlock();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
draw();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Enter and Space pick a seat, as they would on a native button.
|
|
348
|
+
root.onkeydown = (event) => {
|
|
349
|
+
if ((event.key === "Enter" || event.key === " ") && event.target.dataset?.seat) {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
const id = event.target.dataset.seat;
|
|
352
|
+
event.target.click();
|
|
353
|
+
// draw() replaced the node; keep keyboard focus on the same seat.
|
|
354
|
+
root.querySelector(`[data-seat="${id}"]`)?.focus();
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
draw();
|
|
359
|
+
root.__ottoCard = { cancel: () => clearTimeout(timer) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
window.OttoSeatMap = { render };
|
|
363
|
+
|
|
364
|
+
})();
|