@ottotheagent/mcp-card-assets 0.14.0 → 0.16.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
@@ -7,7 +7,7 @@ Renderers, logos and photo placeholders for the Otto flight, hotel, room and car
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)". Since 0.14.0 it tags exit rows and bulkhead rows, from the card's `exit` and `bulkhead` row flags. Also since 0.14.0, each row of the flight card's fare list starts its facts line with the fare's refund wording.
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)". Since 0.14.0 it tags exit rows and bulkhead rows, from the card's `exit` and `bulkhead` row flags. Also since 0.14.0, each row of the flight card's fare list starts its facts line with the fare's refund wording. Since 0.15.0 the seat map draws each lavatory in `card.lavs` as one labeled block over its seat columns. Since 0.16.0 it draws a whole party (`card.travelers`): a side panel lists each traveler's seat, a change shows as "23A → 24C" without sending anything, and **Update seats** sends every change in one message. A lone traveler keeps the one-seat layout.
11
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.
12
12
  - `*-card.css` holds each card's CSS as a separate file, for reference.
13
13
  - `marks/<IATA>.png` holds airline logos, and `marks/cars/<code>.svg` holds car-company logos.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottotheagent/mcp-card-assets",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
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": [
package/seat-map.css CHANGED
@@ -1 +1 @@
1
- :root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme="dark"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}.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);}.smRow.bulkhead{padding-top:var(--otto-space-2);border-top:3px solid var(--otto-text-secondary);}.smTag{flex-shrink:0;margin-left:var(--otto-space-2);font-size:var(--otto-text-sm);color:var(--otto-text-secondary);white-space:nowrap;}.smTag.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;}
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);}.smBody{display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--otto-space-5);}.smBody .smScroll{flex:0 1 auto;min-width:0;}.smSide{flex:0 0 168px;display:flex;flex-direction:column;gap:var(--otto-space-2);}.smSideHead{font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.smTraveler{box-sizing:border-box;padding:var(--otto-space-2) var(--otto-space-3);border:1px solid var(--otto-card-border);border-radius:8px;cursor:pointer;user-select:none;}.smTraveler:hover,.smTraveler.active{border-color:var(--otto-accent);}.smTraveler:focus-visible{outline:2px solid var(--otto-accent);outline-offset:1px;}.smTravelerName{font-size:var(--otto-text-md);color:var(--otto-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.smTravelerSeat{display:flex;align-items:baseline;gap:var(--otto-space-2);font-size:var(--otto-text-base);font-weight:var(--otto-weight-semibold);font-variant-numeric:tabular-nums;}.smTravelerSeat .smFrom{color:var(--otto-text-secondary);font-weight:var(--otto-weight-medium);text-decoration:line-through;}.smTravelerSeat .smArrow{color:var(--otto-text-secondary);}.smTravelerSeat .smTo{color:var(--otto-accent);}.smTravelerNote{font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.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);}.smLav{box-sizing:border-box;flex-shrink:0;height:var(--sm-seat);display:grid;place-items:center;overflow:hidden;border:1px dashed var(--otto-card-border);border-radius:6px;background:var(--otto-strip-bg);color:var(--otto-text-secondary);font-size:var(--otto-text-sm);white-space:nowrap;}.smRow.bulkhead{padding-top:var(--otto-space-2);border-top:3px solid var(--otto-text-secondary);}.smTag{flex-shrink:0;margin-left:var(--otto-space-2);font-size:var(--otto-text-sm);color:var(--otto-text-secondary);white-space:nowrap;}.smTag.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.mine.active{outline:2px solid var(--otto-text-primary);outline-offset:1px;}.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 CHANGED
@@ -11,7 +11,7 @@
11
11
  * spec.bar draws the options as one wrapping row of buttons with no title, for a card
12
12
  * whose details the host already shows above it.
13
13
  */
14
- const CSS = ":root{--otto-card-bg:#ffffff;--otto-card-border:#d4d4d4;--otto-btn-border:#525252;--otto-accent:#08c0cc;--otto-divider:#e5e5e5;--otto-strip-bg:#f5f5f5;--otto-fare-bg:#262624;--otto-on-fare:#ffffff;--otto-chip-bg:#ccfbf1;--otto-chip-text:#0a0a0a;--otto-text-primary:#0a0a0a;--otto-text-body:#0a0a0a;--otto-text-secondary:#737373;--otto-link-hover:#0369a1;--otto-banner-bg:#171717;--otto-on-banner:#ffffff;--otto-caution-bg:#fdf0dd;--otto-caution-text:#96590b;--otto-positive-text:#1c7a3e;--otto-photo-bg:#f5f5f5;--otto-font:Inter,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";--otto-line:1.5;--otto-text-xs:10px;--otto-text-sm:11px;--otto-text-md:12px;--otto-text-base:13px;--otto-text-lg:14px;--otto-text-xl:18px;--otto-text-2xl:19px;--otto-weight-regular:400;--otto-weight-medium:500;--otto-weight-semibold:600;--otto-space-1:2px;--otto-space-2:6px;--otto-space-3:8px;--otto-space-4:10px;--otto-space-5:12px;--otto-space-6:14px;--otto-space-7:16px;--otto-space-8:18px;--otto-radius-card:12px;--otto-radius-fare:10px;--otto-radius-md:8px;--otto-radius-pill:999px;--otto-card-width:304px;--otto-icon-size:14px;--otto-photo-height:170px;--otto-mark-size:24px;--otto-chip-height:24px;--otto-tap-min:44px;}@media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}}:root[data-theme=\"dark\"]{--otto-card-bg:#262624;--otto-card-border:#3d3d3a;--otto-btn-border:#a3a3a3;--otto-divider:#3d3d3a;--otto-strip-bg:#1c1c1b;--otto-fare-bg:#3d3d3a;--otto-text-primary:#ffffff;--otto-text-body:#f5f5f5;--otto-text-secondary:#a3a3a3;--otto-link-hover:#38bdf8;--otto-banner-bg:#3d3d3a;--otto-chip-bg:#0d3d3a;--otto-chip-text:#ccfbf1;--otto-caution-bg:#3b2a12;--otto-caution-text:#f5b65b;--otto-positive-text:#5fd08a;--otto-photo-bg:#1c1c1b;}html,body{margin:0;background:transparent;font-family:var(--otto-font);line-height:var(--otto-line);color:var(--otto-text-primary);}#root{min-height:120px;padding:0;}button{font:inherit;}.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:var(--otto-icon-size);height:var(--otto-icon-size);line-height:1;}.icon svg{width:100%;height:100%;fill:currentColor;}.head{margin-bottom:var(--otto-space-5);}.title{font-size:var(--otto-text-lg);line-height:22px;font-weight:var(--otto-weight-semibold);}.subtitle,.empty{font-size:var(--otto-text-base);color:var(--otto-text-secondary);}.row{display:flex;gap:var(--otto-space-5);overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:var(--otto-space-2);}.opt{flex:0 0 var(--otto-card-width);width:var(--otto-card-width);min-width:0;overflow-wrap:anywhere;box-sizing:border-box;scroll-snap-align:start;display:flex;flex-direction:column;padding:var(--otto-space-7);background:var(--otto-card-bg);border:1px solid var(--otto-card-border);border-radius:var(--otto-radius-card);}.opt.chosen{border-color:var(--otto-accent);}@media (hover:hover){.opt:hover{border-color:var(--otto-accent);}}.chip{display:inline-flex;align-items:center;height:var(--otto-chip-height);padding:0 var(--otto-space-4);box-sizing:border-box;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:var(--otto-radius-pill);font-size:var(--otto-text-sm);font-weight:var(--otto-weight-semibold);background:var(--otto-chip-bg);color:var(--otto-chip-text);}.chip.caution{background:var(--otto-caution-bg);color:var(--otto-caution-text);}.btn{flex-shrink:0;min-height:41px;padding:9px var(--otto-space-7);box-sizing:border-box;white-space:nowrap;cursor:pointer;border-radius:var(--otto-radius-md);font-size:var(--otto-text-lg);font-weight:var(--otto-weight-medium);display:inline-flex;align-items:center;justify-content:center;gap:var(--otto-space-2);background:transparent;color:var(--otto-text-primary);border:1px solid var(--otto-btn-border);}.btn:disabled{cursor:default;opacity:0.45;}.why{margin-top:var(--otto-space-7);font-size:var(--otto-text-base);color:var(--otto-text-body);}.whyHead{font-weight:var(--otto-weight-semibold);}.whyLine{display:flex;gap:var(--otto-space-3);}.tick{--otto-icon-size:12px;height:19.5px;font-size:var(--otto-text-md);}.tick.note{color:var(--otto-text-secondary);}@media (max-width:480px){.opt{flex-basis:min(var(--otto-card-width),82vw);width:min(var(--otto-card-width),82vw);}.btn{min-height:var(--otto-tap-min);}}.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);}.smRow.bulkhead{padding-top:var(--otto-space-2);border-top:3px solid var(--otto-text-secondary);}.smTag{flex-shrink:0;margin-left:var(--otto-space-2);font-size:var(--otto-text-sm);color:var(--otto-text-secondary);white-space:nowrap;}.smTag.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;}";
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);}.smBody{display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--otto-space-5);}.smBody .smScroll{flex:0 1 auto;min-width:0;}.smSide{flex:0 0 168px;display:flex;flex-direction:column;gap:var(--otto-space-2);}.smSideHead{font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.smTraveler{box-sizing:border-box;padding:var(--otto-space-2) var(--otto-space-3);border:1px solid var(--otto-card-border);border-radius:8px;cursor:pointer;user-select:none;}.smTraveler:hover,.smTraveler.active{border-color:var(--otto-accent);}.smTraveler:focus-visible{outline:2px solid var(--otto-accent);outline-offset:1px;}.smTravelerName{font-size:var(--otto-text-md);color:var(--otto-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.smTravelerSeat{display:flex;align-items:baseline;gap:var(--otto-space-2);font-size:var(--otto-text-base);font-weight:var(--otto-weight-semibold);font-variant-numeric:tabular-nums;}.smTravelerSeat .smFrom{color:var(--otto-text-secondary);font-weight:var(--otto-weight-medium);text-decoration:line-through;}.smTravelerSeat .smArrow{color:var(--otto-text-secondary);}.smTravelerSeat .smTo{color:var(--otto-accent);}.smTravelerNote{font-size:var(--otto-text-sm);color:var(--otto-text-secondary);}.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);}.smLav{box-sizing:border-box;flex-shrink:0;height:var(--sm-seat);display:grid;place-items:center;overflow:hidden;border:1px dashed var(--otto-card-border);border-radius:6px;background:var(--otto-strip-bg);color:var(--otto-text-secondary);font-size:var(--otto-text-sm);white-space:nowrap;}.smRow.bulkhead{padding-top:var(--otto-space-2);border-top:3px solid var(--otto-text-secondary);}.smTag{flex-shrink:0;margin-left:var(--otto-space-2);font-size:var(--otto-text-sm);color:var(--otto-text-secondary);white-space:nowrap;}.smTag.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.mine.active{outline:2px solid var(--otto-text-primary);outline-offset:1px;}.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
15
  const ICONS = {"hotels/check-regular-full":"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M534 132.5C544.8 140.2 547.2 155.2 539.5 166L275.5 534C271.4 539.7 265 543.4 258 543.9C251 544.4 244 542 239 537L103 401C93.6 391.6 93.6 376.4 103 367.1C112.4 357.8 127.6 357.7 136.9 367.1L253 483L500.5 138C508.2 127.2 523.2 124.8 534 132.5z\"/></svg>","hotels/circle-info-regular-full":"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM280 400C266.7 400 256 410.7 256 424C256 437.3 266.7 448 280 448L360 448C373.3 448 384 437.3 384 424C384 410.7 373.3 400 360 400L352 400L352 312C352 298.7 341.3 288 328 288L280 288C266.7 288 256 298.7 256 312C256 325.3 266.7 336 280 336L304 336L304 400L280 400zM320 256C337.7 256 352 241.7 352 224C352 206.3 337.7 192 320 192C302.3 192 288 206.3 288 224C288 241.7 302.3 256 320 256z\"/></svg>"};
16
16
  const SYMBOLS = { USD: "$", EUR: "€", GBP: "£", JPY: "¥", CNY: "¥", AUD: "A$", CAD: "C$" };
17
17
  // The widget cannot see when the host's reply ends, so the lock lifts on a timer.
@@ -171,35 +171,57 @@ function mount(root, card, spec) {
171
171
 
172
172
  /* Otto seat map: OttoSeatMap.render(root, card), card from render_elastic_seat_map.
173
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. */
174
+ * and cabin draws the same way. Collapsed, it shows the travelers' rows and one row either
175
+ * side; Expand shows the whole cabin. A lavatory (card.lavs) draws as one labeled block over its
176
+ * seat columns; a lavatory row without seats becomes a row of its own.
177
+ *
178
+ * One traveler: clicking another seat marks it New selection and shows "Update seat to 15D",
179
+ * which sends "Update seat to 15D (SEA → JFK)" as the traveler's message.
180
+ * A party (2+ card.travelers): a side panel lists each traveler and seat. Click a party seat,
181
+ * then a new seat: nothing is sent, the panel shows "23A → 24C". "Update seats" sends every
182
+ * change at once: "Update seats (SEA → JFK): Ana 23A → 24C, Ben 23B → 24D". */
178
183
 
179
184
  function render(root, card) {
180
185
  injectCss("otto-seat-map-css");
181
186
  if (root.__ottoCard) root.__ottoCard.cancel();
182
- if (!card || !card.rows || !card.rows.length) {
187
+ const travelers = (card && card.travelers) || [];
188
+ if (!card || !card.rows || !card.rows.length || !travelers.length) {
183
189
  root.replaceChildren(el("p", "empty", "No seat map to show."));
184
190
  return;
185
191
  }
186
192
 
193
+ // Row number → lavatory seat letters. Seatless lavatory rows join the rows in number order.
194
+ const lavs = new Map((card.lavs || []).map((lav) => [lav.row, lav.cols]));
195
+ const rows = [...card.rows];
196
+ for (const number of lavs.keys()) {
197
+ if (!rows.some((row) => row.number === number)) rows.push({ number, seats: card.columns.map(() => null), lavOnly: true });
198
+ }
199
+ rows.sort((a, b) => a.number - b.number);
187
200
  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;
201
+ rows.forEach((row, index) => row.seats.forEach((seat) => seat && seats.set(seat.id, { seat, index })));
190
202
  const aisles = new Set(card.aisles_after || []);
203
+
204
+ const party = travelers.length > 1;
205
+ const owner = new Map(travelers.map((traveler, i) => [traveler.seat, i])); // seat id → traveler index
206
+ const homes = travelers.map((t) => seats.get(t.seat)?.index ?? 0);
207
+ const first = Math.min(...homes);
208
+ const last = Math.max(...homes);
209
+
191
210
  let expanded = false;
192
- let pick = null;
193
211
  let busy = false;
194
212
  let timer = null;
213
+ let pick = null; // one traveler: the new seat
214
+ let active = null; // a party: the traveler whose seat is being moved
215
+ const moves = new Map(); // a party: traveler index → new seat id
195
216
 
196
217
  const letter = (id) => String(id).replace(/^\d+/, "");
197
218
  const cost = (seat) => (seat.price ? formatExactMoney(seat.price, card.currency || "USD") : "free");
198
219
  const describe = (seat) => [seat.id, seat.type, cost(seat)].filter(Boolean).join(" · ");
220
+ const movedTo = (id) => [...moves.entries()].find(([, to]) => to === id)?.[0];
199
221
 
200
222
  function seatClass(seat) {
201
- if (seat.id === card.current_seat) return "mine";
202
- if (seat.id === pick) return "pick";
223
+ if (owner.has(seat.id)) return "mine" + (party && active === owner.get(seat.id) ? " active" : "");
224
+ if (party ? movedTo(seat.id) !== undefined : seat.id === pick) return "pick";
203
225
  return { free: "free", paid: "paid", unavailable: "off" }[seat.state] || "off";
204
226
  }
205
227
 
@@ -222,11 +244,13 @@ function render(root, card) {
222
244
  button.setAttribute("role", "button");
223
245
  button.tabIndex = busy ? -1 : 0;
224
246
  button.dataset.seat = seat.id;
225
- button.title = [describe(seat), seat.extra_legroom && "extra legroom", ...facts.map((f) => f.toLowerCase())]
226
- .filter(Boolean)
227
- .join(" · ") + (cls === "pick" ? " · new selection" : "");
247
+ const who = party && owner.has(seat.id) ? travelers[owner.get(seat.id)].name : null;
248
+ button.title =
249
+ [who, describe(seat), seat.extra_legroom && "extra legroom", ...facts.map((f) => f.toLowerCase())]
250
+ .filter(Boolean)
251
+ .join(" · ") + (cls === "pick" ? " · new selection" : "");
228
252
  button.setAttribute("aria-label", button.title);
229
- button.setAttribute("aria-pressed", String(cls === "mine" || cls === "pick"));
253
+ button.setAttribute("aria-pressed", String(cls.startsWith("mine") || cls === "pick"));
230
254
  return button;
231
255
  }
232
256
 
@@ -240,14 +264,36 @@ function render(root, card) {
240
264
  return line;
241
265
  }
242
266
 
267
+ // One block over a run of empty lavatory cells, as wide as the seats and aisles it replaces.
268
+ function lavBlock(count, aisleCount) {
269
+ const block = el("span", "smLav", count >= 3 ? "Lavatory" : "WC");
270
+ block.style.width = `calc(var(--sm-seat) * ${count} + var(--sm-aisle) * ${aisleCount} + var(--sm-gap) * ${count + aisleCount - 1})`;
271
+ block.title = "Lavatory";
272
+ block.setAttribute("role", "img");
273
+ block.setAttribute("aria-label", "Lavatory");
274
+ return block;
275
+ }
276
+
243
277
  // An exit row or bulkhead row gets a tag after its last seat; a bulkhead also draws the wall above it.
244
278
  function seatRow(row) {
245
279
  const facts = rowFacts(row);
280
+ const lav = lavs.get(row.number) || "";
281
+ const isLav = (i) => !row.seats[i] && lav.includes(card.columns[i]);
246
282
  const line = el("div", "smRow" + (row.bulkhead ? " bulkhead" : ""));
247
- const number = el("span", "smNum" + (row.exit ? " exit" : ""), row.number);
283
+ const number = el("span", "smNum" + (row.exit ? " exit" : ""), row.lavOnly ? "" : row.number);
248
284
  if (facts.length) number.title = facts.join(" · ");
249
285
  line.append(number);
250
286
  row.seats.forEach((seat, i) => {
287
+ if (isLav(i)) {
288
+ if (i > 0 && isLav(i - 1)) return;
289
+ let end = i;
290
+ while (end + 1 < row.seats.length && isLav(end + 1)) end += 1;
291
+ let inner = 0;
292
+ for (let k = i; k < end; k += 1) if (aisles.has(k)) inner += 1;
293
+ line.append(lavBlock(end - i + 1, inner));
294
+ if (aisles.has(end)) line.append(el("span", "smAisle"));
295
+ return;
296
+ }
251
297
  line.append(seat ? seatNode(seat, facts) : el("span", "smEmpty"));
252
298
  if (aisles.has(i)) line.append(el("span", "smAisle"));
253
299
  });
@@ -260,7 +306,7 @@ function render(root, card) {
260
306
  function legend() {
261
307
  const states = new Set([...seats.values()].map(({ seat }) => seat.state));
262
308
  const keys = [
263
- ["mine", "Your seat", true],
309
+ ["mine", party ? "Your party" : "Your seat", true],
264
310
  ["pick", "New selection", true],
265
311
  ["free", "Free", states.has("free")],
266
312
  ["paid", "Paid", states.has("paid")],
@@ -283,15 +329,66 @@ function render(root, card) {
283
329
  return box;
284
330
  }
285
331
 
332
+ // A party's side panel: each traveler's seat, or "23A → 24C" with the new seat's details.
333
+ function sidePanel() {
334
+ const side = el("div", "smSide");
335
+ const hint =
336
+ active !== null ? `Pick a new seat for ${travelers[active].name}` : moves.size ? "Seat changes" : "Tap a seat to change it";
337
+ side.append(el("div", "smSideHead", hint));
338
+ travelers.forEach((traveler, i) => {
339
+ const to = moves.get(i);
340
+ const row = el("div", "smTraveler" + (active === i ? " active" : ""));
341
+ row.setAttribute("role", "button");
342
+ row.tabIndex = busy ? -1 : 0;
343
+ row.dataset.traveler = String(i);
344
+ const seatLine = el("div", "smTravelerSeat");
345
+ if (to) {
346
+ seatLine.append(el("span", "smFrom", traveler.seat), el("span", "smArrow", "→"), el("span", "smTo", to));
347
+ } else {
348
+ seatLine.append(el("span", null, traveler.seat));
349
+ }
350
+ row.append(el("div", "smTravelerName", traveler.name), seatLine);
351
+ const target = to && seats.get(to)?.seat;
352
+ if (target) row.append(el("div", "smTravelerNote", [target.type, cost(target)].filter(Boolean).join(" · ")));
353
+ side.append(row);
354
+ });
355
+ return side;
356
+ }
357
+
358
+ function footer() {
359
+ const foot = el("div", "smFoot");
360
+ if (party) {
361
+ const count = moves.size;
362
+ foot.append(el("span", null, count ? `${count} seat change${count === 1 ? "" : "s"}` : ""));
363
+ if (count) {
364
+ const update = el("button", "btn", "Update seats");
365
+ update.type = "button";
366
+ update.dataset.update = "party";
367
+ foot.append(update);
368
+ }
369
+ return foot;
370
+ }
371
+ const mine = seats.get(travelers[0].seat)?.seat;
372
+ const chosen = pick && seats.get(pick)?.seat;
373
+ foot.append(el("span", null, chosen ? `New selection: ${describe(chosen)}` : mine ? `Your seat: ${describe(mine)}` : ""));
374
+ if (chosen) {
375
+ const update = el("button", "btn", `Update seat to ${chosen.id}`);
376
+ update.type = "button";
377
+ update.dataset.update = chosen.id;
378
+ foot.append(update);
379
+ }
380
+ return foot;
381
+ }
382
+
286
383
  function draw() {
287
384
  const scroll = root.querySelector(".smScroll")?.scrollLeft || 0;
288
- const box = el("div", `sm ${card.size || "standard"}${busy ? " busy" : ""}`);
385
+ const box = el("div", `sm ${card.size || "standard"}${party ? " party" : ""}${busy ? " busy" : ""}`);
289
386
 
290
387
  const head = el("div", "smHead");
291
388
  const text = el("div");
292
389
  text.append(el("div", "title", card.title), el("div", "subtitle", card.subtitle));
293
390
  head.append(text);
294
- if (card.rows.length > 3) {
391
+ if (rows.length > last - first + 3) {
295
392
  const toggle = el("button", "btn smToggle", expanded ? "Collapse" : "Expand");
296
393
  toggle.type = "button";
297
394
  toggle.dataset.toggle = "1";
@@ -299,24 +396,16 @@ function render(root, card) {
299
396
  head.append(toggle);
300
397
  }
301
398
 
302
- const rows = expanded ? card.rows : card.rows.slice(Math.max(0, home - 1), home + 2);
399
+ const visible = expanded ? rows : rows.slice(Math.max(0, first - 1), last + 2);
303
400
  const grid = el("div", "smGrid");
304
- grid.append(lettersRow(), ...rows.map(seatRow));
401
+ grid.append(lettersRow(), ...visible.map(seatRow));
305
402
  const scroller = el("div", "smScroll");
306
403
  scroller.append(grid);
404
+ const body = el("div", "smBody");
405
+ body.append(scroller);
406
+ if (party) body.append(sidePanel());
307
407
 
308
- const foot = el("div", "smFoot");
309
- const mine = seats.get(card.current_seat)?.seat;
310
- const chosen = pick && seats.get(pick)?.seat;
311
- foot.append(el("span", null, chosen ? `New selection: ${describe(chosen)}` : mine ? `Your seat: ${describe(mine)}` : ""));
312
- if (chosen) {
313
- const update = el("button", "btn", `Update seat to ${chosen.id}`);
314
- update.type = "button";
315
- update.dataset.update = chosen.id;
316
- foot.append(update);
317
- }
318
-
319
- box.append(head, scroller, legend(), foot);
408
+ box.append(head, body, legend(), footer());
320
409
  root.replaceChildren(box);
321
410
  scroller.scrollLeft = scroll;
322
411
  root.querySelectorAll("button").forEach((b) => (b.disabled = busy));
@@ -328,38 +417,70 @@ function render(root, card) {
328
417
  draw();
329
418
  }
330
419
 
420
+ function send(message) {
421
+ busy = true;
422
+ timer = setTimeout(unlock, BUSY_MS);
423
+ try {
424
+ const sent = window.sendPrompt(message);
425
+ if (sent && typeof sent.catch === "function") sent.catch(unlock);
426
+ } catch (error) {
427
+ unlock();
428
+ }
429
+ }
430
+
431
+ // A party: a party seat (or its row in the panel) picks who moves; another seat is the new
432
+ // seat; clicking a pending new seat again cancels that change.
433
+ function partyClick(id) {
434
+ if (owner.has(id)) {
435
+ const i = owner.get(id);
436
+ active = active === i ? null : i;
437
+ return;
438
+ }
439
+ const cancelled = movedTo(id);
440
+ if (cancelled !== undefined) {
441
+ moves.delete(cancelled);
442
+ active = null;
443
+ return;
444
+ }
445
+ if (active === null) return;
446
+ moves.set(active, id);
447
+ active = null;
448
+ }
449
+
331
450
  // Assigned, not added: a re-mount replaces the handler instead of stacking another.
332
451
  root.onclick = (event) => {
333
452
  if (busy) return;
334
- const target = event.target.closest("button, [data-seat]");
453
+ const target = event.target.closest("button, [data-seat], [data-traveler]");
335
454
  if (!target) return;
336
455
  if (target.dataset.toggle) {
337
456
  expanded = !expanded;
457
+ } else if (target.dataset.traveler) {
458
+ const i = Number(target.dataset.traveler);
459
+ active = active === i ? null : i;
338
460
  } else if (target.dataset.seat) {
339
461
  const id = target.dataset.seat;
340
- pick = id === card.current_seat || id === pick ? null : id;
462
+ if (party) partyClick(id);
463
+ else pick = id === travelers[0].seat || id === pick ? null : id;
464
+ } else if (target.dataset.update === "party") {
465
+ const changes = travelers
466
+ .map((traveler, i) => moves.has(i) && `${traveler.name} ${traveler.seat} → ${moves.get(i)}`)
467
+ .filter(Boolean);
468
+ send(`Update seats (${card.route}): ${changes.join(", ")}`);
341
469
  } else if (target.dataset.update) {
342
- busy = true;
343
- timer = setTimeout(unlock, BUSY_MS);
344
- try {
345
- const sent = window.sendPrompt(`Update seat to ${target.dataset.update} (${card.route})`);
346
- if (sent && typeof sent.catch === "function") sent.catch(unlock);
347
- } catch (error) {
348
- unlock();
349
- return;
350
- }
470
+ send(`Update seat to ${target.dataset.update} (${card.route})`);
351
471
  }
352
472
  draw();
353
473
  };
354
474
 
355
- // Enter and Space pick a seat, as they would on a native button.
475
+ // Enter and Space act as a click on a seat or a traveler, as they would on a native button.
356
476
  root.onkeydown = (event) => {
357
- if ((event.key === "Enter" || event.key === " ") && event.target.dataset?.seat) {
477
+ const data = event.target.dataset || {};
478
+ if ((event.key === "Enter" || event.key === " ") && (data.seat || data.traveler)) {
358
479
  event.preventDefault();
359
- const id = event.target.dataset.seat;
480
+ const selector = data.seat ? `[data-seat="${data.seat}"]` : `[data-traveler="${data.traveler}"]`;
360
481
  event.target.click();
361
- // draw() replaced the node; keep keyboard focus on the same seat.
362
- root.querySelector(`[data-seat="${id}"]`)?.focus();
482
+ // draw() replaced the node; keep keyboard focus on the same one.
483
+ root.querySelector(selector)?.focus();
363
484
  }
364
485
  };
365
486