diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541150..ccc1cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ### New Features +- **`render_map_tool`: results carousel at the bottom of the map instead of the side list (#292).** Search results and id-bearing markers now show as a row of swipeable cards along the bottom, like the place results in ChatGPT, instead of the 280 px list on the right, which covered much of an inline map or started collapsed below 560 px wide. Each card has the place's name, category, distance and popularity, its photo once loaded, and a second line with today's hours (from Place Details `opening_hours`) and phone number. + - **Place pins instead of orange numbers:** places are white pins with a [Maki](https://github.com/mapbox/maki) icon for their category (the Maki name Search Box gives each result, or one matched from the category, falling back to a plain marker), like ChatGPT's place results. About 20 common icons are inlined (about 12 KB). A caller's color tints the icon. Numbered badges stay for markers whose number means something: a caller's `style: "numbered"` with a `label` (a visit order), optimized trip stops, and route start/end. + - **Card and pin stay in sync:** the selected card gets a border, and its pin turns black (a numbered badge turns white), comes to the front and shows the place's name under it. The first card starts selected. Swiping selects whichever card ends up in the middle, clicking a card selects it, and clicking a pin scrolls to its card. Picking a card or a pin moves the map only when the pin or its name label is off screen or behind the cards, and then only far enough to show them. + - **No popups on carousel pins:** a pin with a card no longer opens a popup, so the phone number moved from the popup to the card. Pins without an id (route ends, waypoints) keep their popups. + - **Hours:** parsed from the OSM-style `opening_hours` string for simple rules (`Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00`, `Su off`, `24/7`, several ranges a day). Anything more complex (holidays, months) shows no hours rather than wrong ones. "Today" is the viewer's day, which can differ from the place's near midnight across time zones. + - **Layout:** the cards sit 36 px up so the Mapbox logo and attribution stay visible, the legend moves up above them, and the map fits its data above both. The collapse toggle and the right-hand fit padding are gone. The zoom buttons move down below the summary chip ("6 results for …") while it shows, instead of sitting under it (same fix as #278). + - **OpenAI's [UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines):** every card has a picture (the place's icon on a muted tile until a photo loads, or when there is none), labeled with the place's name for screen readers. Numbered badges pick dark or white numbers by contrast with the badge color (WCAG). The tool description asks the model not to list the places again after rendering, since the cards already show them. + - **Light and dark:** the cards, summary chip, legend and messages follow the host's theme (MCP Apps `hostContext.theme`, at start and on `host-context-changed`), or the system's `prefers-color-scheme` when the host sends none. Colors come from the host's style variables (`--color-background-primary`, `--color-text-primary` and so on) when it sends them, so the cards match the chat around them. The map, its pins and their name labels look the same in both. + - Tool and resource descriptions now say "carousel" instead of "panel". Dedupe across merged payloads, the batched Place Details lookup and photo loading are unchanged. + - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. - **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting. diff --git a/src/resources/place-photo/PlacePhotoResource.ts b/src/resources/place-photo/PlacePhotoResource.ts index 302152f..70c95c8 100644 --- a/src/resources/place-photo/PlacePhotoResource.ts +++ b/src/resources/place-photo/PlacePhotoResource.ts @@ -51,7 +51,7 @@ const lookupAll: LookupHost = async (hostname) => /** * A POI's first Place Details photo, proxied through the server for the map - * app's results panel. + * app's results carousel. * * Place Details photos are hosted wherever the business's own site hosts * them (Yext, Webflow and so on), so the map app's CSP can't list their @@ -67,7 +67,7 @@ export class PlacePhotoResource extends MapboxApiBasedResource { readonly uri = `${URI_PREFIX}{id}`; readonly name = 'Mapbox Place Photo'; readonly description = - "The first photo of a POI from Place Details, as an image, for the map app's results panel. " + + "The first photo of a POI from Place Details, as an image, for the map app's results carousel. " + 'Read with mapbox://place-photo/{mapbox_id}.'; readonly mimeType = 'image/jpeg'; diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index f832287..740c2b4 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -22,7 +22,7 @@ export const ALL_RESOURCES = [ new ToolResponseResource({ httpRequest }), // Single shared map renderer, targeted exclusively by render_map_tool. new MapAppUIResource({ httpRequest }), - // Photos for the map app's results panel, which its CSP can't load directly. + // Photos for the map app's results carousel, which its CSP can't load directly. new PlacePhotoResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 60056e1..26fb3e0 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -45,23 +45,48 @@ export function renderMapAppHtml(params: {
- +
Loading…
${initialDataScript} @@ -143,9 +201,9 @@ ${initialDataScript} var errorEl = document.getElementById('error'); var summaryEl = document.getElementById('summary'); var legendEl = document.getElementById('legend'); - var panelEl = document.getElementById('side-panel'); - // Populated by buildSearchMiniPayload's marker tagging so the panel (click - // -> flyTo) and Place Details enrichment can find a marker by mapbox_id + var carouselEl = document.getElementById('carousel'); + // Populated by buildSearchMiniPayload's marker tagging so the carousel (card + // <-> pin) and Place Details enrichment can find a marker by mapbox_id // without re-querying the map for it. var markersById = {}; @@ -213,12 +271,56 @@ ${initialDataScript} handleToolResult(message.params); } if (message.method === 'ui/notifications/host-context-changed' && - message.params && message.params.displayMode) { - currentDisplayMode = message.params.displayMode; - if (map) setTimeout(function() { map.resize(); }, 100); + message.params) { + // Partial updates: only the fields that changed are sent. + applyHostContext(message.params); + if (message.params.displayMode) { + currentDisplayMode = message.params.displayMode; + if (map) setTimeout(function() { map.resize(); }, 100); + } } }); + // --- Light/dark theme ----------------------------------------------------- + var rootEl = document.documentElement; + var hostTheme = null; + + function applyTheme(theme) { + if ((theme !== 'light' && theme !== 'dark') || !rootEl) return; + rootEl.setAttribute('data-theme', theme); + } + + // The host's theme wins over the system's; its style variables + // (--color-background-primary and so on) feed the --ui-* colors. + function applyHostContext(ctx) { + if (!ctx || typeof ctx !== 'object') return; + if (ctx.theme === 'light' || ctx.theme === 'dark') { + hostTheme = ctx.theme; + applyTheme(hostTheme); + } + var vars = ctx.styles && ctx.styles.variables; + if (vars && typeof vars === 'object' && rootEl && rootEl.style && + typeof rootEl.style.setProperty === 'function') { + Object.keys(vars).forEach(function(name) { + if (/^--[a-z0-9-]+$/i.test(name) && typeof vars[name] === 'string') { + rootEl.style.setProperty(name, vars[name]); + } + }); + } + } + window.__applyHostContext = applyHostContext; + + var darkQuery = typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + function followSystemTheme() { + if (!hostTheme && darkQuery) applyTheme(darkQuery.matches ? 'dark' : 'light'); + } + followSystemTheme(); + if (darkQuery && typeof darkQuery.addEventListener === 'function') { + darkQuery.addEventListener('change', followSystemTheme); + } + // Set once a host answers ui/initialize, even with an error. A page from // export_map_html_tool framed by some other site never gets an answer. var hostAnswered = false; @@ -227,8 +329,9 @@ ${initialDataScript} appCapabilities: {}, appInfo: { name: 'Mapbox Map', version: '1.0.0' } }).then( - function() { + function(result) { hostAnswered = true; + if (result) applyHostContext(result.hostContext); sendNotification('ui/notifications/initialized', {}); }, function() { @@ -415,6 +518,7 @@ ${initialDataScript} } else { summaryEl.style.display = 'none'; } + placeTopLeftControls(); if (!map) { loadingEl.style.display = 'none'; return; } if (mapLoaded) render(payload); else pendingPayload = payload; @@ -439,7 +543,8 @@ ${initialDataScript} trackedSourceIds = []; trackedMarkers = []; markersById = {}; - panelEl.style.display = 'none'; + carouselEl.style.display = 'none'; + legendEl.style.bottom = ''; clearPanel(); // Place Details responses still in flight belong to the old render. panelGeneration += 1; @@ -475,10 +580,105 @@ ${initialDataScript} end: { color: '#ef4444' } }; + // Maki icons (https://github.com/mapbox/maki, CC0), 15x15 path data, for + // the categories search results most often have. Anything else gets + // the plain marker. + var MAKI = { + 'attraction': 'M6,2C5.446,2,5.2478,2.5045,5,3L4.5,4h-2C1.669,4,1,4.669,1,5.5v5C1,11.331,1.669,12,2.5,12h10 c0.831,0,1.5-0.669,1.5-1.5v-5C14,4.669,13.331,4,12.5,4h-2L10,3C9.75,2.5,9.554,2,9,2H6z M2.5,5C2.7761,5,3,5.2239,3,5.5 S2.7761,6,2.5,6S2,5.7761,2,5.5S2.2239,5,2.5,5z M7.5,5c1.6569,0,3,1.3431,3,3s-1.3431,3-3,3s-3-1.3431-3-3S5.8431,5,7.5,5z M7.5,6.5C6.6716,6.5,6,7.1716,6,8l0,0c0,0.8284,0.6716,1.5,1.5,1.5l0,0C8.3284,9.5,9,8.8284,9,8l0,0C9,7.1716,8.3284,6.5,7.5,6.5 L7.5,6.5z', + 'bakery': 'M5.294,4.382,6,9.5a.979.979,0,0,0,1,1H8a.979.979,0,0,0,1-1l.706-5.118C9.706,3,7.5,3,7.5,3S5.291,3,5.294,4.382ZM3.5,5C2,5,2,6,2,6l1,4H4.5a.793.793,0,0,0,.794-.765L4.5,5Zm-2,2.5a1.533,1.533,0,0,0-1.059.412A1.366,1.366,0,0,0,0,8.794V11H.882A1.02,1.02,0,0,0,2,10ZM11.5,5C13,5,13,6,13,6l-1,4H10.5a.793.793,0,0,1-.794-.765L10.5,5Zm2,2.5a1.533,1.533,0,0,1,1.059.412A1.366,1.366,0,0,1,15,8.794V11h-.882A1.02,1.02,0,0,1,13,10Z', + 'bank': 'M1,3C0.446,3,0,3.446,0,4v7c0,0.554,0.446,1,1,1h13c0.554,0,1-0.446,1-1V4c0-0.554-0.446-1-1-1H1z M1,4h1.5 C2.7761,4,3,4.2239,3,4.5S2.7761,5,2.5,5S2,4.7761,2,4.5L1.5,5C1.7761,5,2,5.2239,2,5.5S1.7761,6,1.5,6S1,5.7761,1,5.5V4z M7.5,4 C8.8807,4,10,5.567,10,7.5l0,0C10,9.433,8.8807,11,7.5,11S5,9.433,5,7.5S6.1193,4,7.5,4z M12.5,4H14v1.5C14,5.7761,13.7761,6,13.5,6 S13,5.7761,13,5.5S13.2239,5,13.5,5L13,4.5C13,4.7761,12.7761,5,12.5,5S12,4.7761,12,4.5S12.2239,4,12.5,4z M7.5,5.5 c-0.323,0-0.5336,0.1088-0.6816,0.25h1.3633C8.0336,5.6088,7.823,5.5,7.5,5.5z M6.625,6C6.5795,6.091,6.5633,6.1711,6.5449,6.25 h1.9102C8.4367,6.1711,8.4205,6.091,8.375,6H6.625z M6.5,6.5v0.25h2V6.5H6.5z M6.5,7v0.25h2V7H6.5z M6.5,7.5v0.25h2V7.5H6.5z M6.5,8 L6.25,8.25h2L8.5,8H6.5z M6,8.5c0,0,0.0353,0.1024,0.1016,0.25H8.375L8,8.5H6z M1.5,9C1.7761,9,2,9.2239,2,9.5S1.7761,10,1.5,10 L2,10.5C2,10.2239,2.2239,10,2.5,10S3,10.2239,3,10.5S2.7761,11,2.5,11H1V9.5C1,9.2239,1.2239,9,1.5,9z M6.2383,9 C6.2842,9.0856,6.3144,9.159,6.375,9.25h2.2676C8.7092,9.1121,8.75,9,8.75,9H6.2383z M13.5,9C13.7761,9,14,9.2239,14,9.5V11h-1.5 c-0.2761,0-0.5-0.2239-0.5-0.5s0.2239-0.5,0.5-0.5s0.5,0.2239,0.5,0.5l0.5-0.5C13.2239,10,13,9.7761,13,9.5S13.2239,9,13.5,9z M6.5664,9.5c0.0786,0.0912,0.1647,0.1763,0.2598,0.25h1.4199C8.3462,9.6727,8.4338,9.5883,8.5,9.5H6.5664z', + 'bar': 'M7.5,1c-2,0-7,0.25-6.5,0.75L7,8v4 c0,1-3,0.5-3,2h7c0-1.5-3-1-3-2V8l6-6.25C14.5,1.25,9.5,1,7.5,1z M7.5,2c2.5,0,4.75,0.25,4.75,0.25L11.5,3h-8L2.75,2.25 C2.75,2.25,5,2,7.5,2z', + 'beer': 'M12,5V2c0,0-1-1-4.5-1S3,2,3,2v3c0.0288,1.3915,0.3706,2.7586,1,4c0.6255,1.4348,0.6255,3.0652,0,4.5c0,0,0,1,3.5,1 s3.5-1,3.5-1c-0.6255-1.4348-0.6255-3.0652,0-4.5C11.6294,7.7586,11.9712,6.3915,12,5z M7.5,13.5 c-0.7966,0.035-1.5937-0.0596-2.36-0.28c0.203-0.7224,0.304-1.4696,0.3-2.22h4.12c-0.004,0.7504,0.097,1.4976,0.3,2.22 C9.0937,13.4404,8.2966,13.535,7.5,13.5z M7.5,5C6.3136,5.0299,5.1306,4.8609,4,4.5v-2C5.131,2.1411,6.3137,1.9722,7.5,2 C8.6863,1.9722,9.869,2.1411,11,2.5v2C9.8694,4.8609,8.6864,5.0299,7.5,5z', + 'cafe': 'M12,5h-2V3H2v4c0.0133,2.2091,1.8149,3.9891,4.024,3.9758C7.4345,10.9673,8.7362,10.2166,9.45,9H12c1.1046,0,2-0.8954,2-2 S13.1046,5,12,5z M12,8H9.86C9.9487,7.6739,9.9958,7.3379,10,7V6h2c0.5523,0,1,0.4477,1,1S12.5523,8,12,8z M10,12.5 c0,0.2761-0.2239,0.5-0.5,0.5h-7C2.2239,13,2,12.7761,2,12.5S2.2239,12,2.5,12h7C9.7761,12,10,12.2239,10,12.5z', + 'cinema': 'M14,7.5v2c0,0.2761-0.2239,0.5-0.5,0.5S13,9.7761,13,9.5c0,0,0.06-0.5-1-0.5h-1v2.5c0,0.2761-0.2239,0.5-0.5,0.5h-8 C2.2239,12,2,11.7761,2,11.5v-4C2,7.2239,2.2239,7,2.5,7h8C10.7761,7,11,7.2239,11,7.5V8h1c1.06,0,1-0.5,1-0.5 C13,7.2239,13.2239,7,13.5,7S14,7.2239,14,7.5z M4,3C2.8954,3,2,3.8954,2,5s0.8954,2,2,2s2-0.8954,2-2S5.1046,3,4,3z M4,6 C3.4477,6,3,5.5523,3,5s0.4477-1,1-1s1,0.4477,1,1S4.5523,6,4,6z M8.5,2C7.1193,2,6,3.1193,6,4.5S7.1193,7,8.5,7S11,5.8807,11,4.5 S9.8807,2,8.5,2z M8.5,6C7.6716,6,7,5.3284,7,4.5S7.6716,3,8.5,3S10,3.6716,10,4.5S9.3284,6,8.5,6z', + 'convenience': 'M2.9982,3.0687s.1875-1.062,1-1.062H5.0043V1.7276a.5.5,0,0,1,.5-.5H9.4882a.5.5,0,0,1,.5.5v.2791h1.01a.8324.8324,0,0,1,.6373.2937,1.7589,1.7589,0,0,1,.3627.7683s.3108,1.5719.5921,2.9181h-.938L11,3,3.982,2.9956,3.3336,5.9868H2.382Zm11,4.9144a1,1,0,0,1-.9992,1l-1.0008,3.973s-.1875,1.0417-1,1.0417h-7c-.8125,0-1-1.0417-1-1.0417L1.9822,8.98a.9985.9985,0,0,1,.016-1.9968h11A1,1,0,0,1,13.9982,7.9831ZM5.0327,12.13,5.03,11.0051,4.6392,9.4686a.5373.5373,0,1,0-1.0649.1445L3.78,11.0075l.1874,1.2672A.5373.5373,0,1,0,5.0327,12.13Zm2-2.5967A.5332.5332,0,0,0,6.4994,9l-.0039.0008L6.4886,9a.4992.4992,0,0,0-.3575.1509.529.529,0,0,0-.1648.3828v2.68a.5332.5332,0,1,0,1.0663,0Zm1.998,0A.5314.5314,0,0,0,8.694,9.04a.4992.4992,0,0,0-.393,0,.5312.5312,0,0,0-.3366.4934v2.68a.5331.5331,0,1,0,1.0662,0Zm2.3857.08a.5373.5373,0,1,0-1.0649-.1445L9.96,10.9938,9.9579,12.13a.5373.5373,0,1,0,1.0649.1446Z', + 'fast-food': 'M14,8c0,0.5523-0.4477,1-1,1H2C1.4477,9,1,8.5523,1,8s0.4477-1,1-1h11C13.5523,7,14,7.4477,14,8z M3.5,10H2 c0,1.6569,1.3431,3,3,3h5c1.6569,0,3-1.3431,3-3H3.5z M3,6H2V4c0-1.1046,0.8954-2,2-2h7c1.1046,0,2,0.8954,2,2v2H3z M11,4.5 C11,4.7761,11.2239,5,11.5,5S12,4.7761,12,4.5S11.7761,4,11.5,4S11,4.2239,11,4.5z M9,3.5C9,3.7761,9.2239,4,9.5,4S10,3.7761,10,3.5 S9.7761,3,9.5,3S9,3.2239,9,3.5z M7,4.5C7,4.7761,7.2239,5,7.5,5S8,4.7761,8,4.5S7.7761,4,7.5,4S7,4.2239,7,4.5z M5,3.5 C5,3.7761,5.2239,4,5.5,4S6,3.7761,6,3.5S5.7761,3,5.5,3S5,3.2239,5,3.5z M3,4.5C3,4.7761,3.2239,5,3.5,5S4,4.7761,4,4.5 S3.7761,4,3.5,4S3,4.2239,3,4.5z', + 'fuel': 'm14 6v5.5c0 .2761-.2239.5-.5.5s-.5-.2239-.5-.5v-2c0-.8284-.6716-1.5-1.5-1.5h-1.5v-6c0-.5523-.4477-1-1-1h-6c-.5523 0-1 .4477-1 1v11c0 .5523.4477 1 1 1h6c.5523 0 1-.4477 1-1v-4h1.5c.2761 0 .5.2239.5.5v2c0 .8284.6716 1.5 1.5 1.5s1.5-.6716 1.5-1.5v-6.5c0-.5523-.4477-1-1-1v-1.51c-.0054-.2722-.2277-.4901-.5-.49-.2816.0047-.5062.2367-.5015.5184.0002.0105.0007.0211.0015.0316v2.45c0 .5523.4477 1 1 1s1-.4477 1-1-.4477-1-1-1zm-5 .5c0 .2761-.2239.5-.5.5h-5c-.2761 0-.5-.2239-.5-.5v-3c0-.2761.2239-.5.5-.5h5c.2761 0 .5.2239.5.5z', + 'grocery': 'M 13.199219 1.5 C 13.199219 1.5 11.808806 1.4588 11.253906 2 C 10.720406 2.5202 10.5 2.9177 10.5 4 L 1.1992188 4 L 2.59375 8.8144531 C 2.59725 8.8217531 2.6036219 8.8287375 2.6074219 8.8359375 C 2.8418219 9.4932375 3.4545469 9.9666406 4.1855469 9.9941406 C 4.1885469 9.9954406 4.1992187 10 4.1992188 10 L 10.699219 10 L 10.699219 10.199219 C 10.699219 10.199219 10.7 10.500391 10.5 10.900391 C 10.3 11.300391 10.200391 11.5 9.4003906 11.5 L 2.9003906 11.5 C 1.9003906 11.5 1.9003906 13 2.9003906 13 L 4.0996094 13 L 4.1992188 13 L 9.0996094 13 L 9.1992188 13 L 9.3007812 13 C 10.500781 13 11.399219 12.299609 11.699219 11.599609 C 11.999219 10.899609 12 10.300781 12 10.300781 L 12 10 L 12 4 C 12 3.4764 12.228619 3 12.699219 3 L 13.25 3 C 13.6642 3 14 2.6642 14 2.25 C 14 1.8358 13.6642 1.5 13.25 1.5 L 13.199219 1.5 z M 9.1992188 13 C 8.5992188 13 8.1992188 13.4 8.1992188 14 C 8.1992188 14.6 8.5992187 15 9.1992188 15 C 9.7992187 15 10.199219 14.6 10.199219 14 C 10.199219 13.4 9.7992188 13 9.1992188 13 z M 4.1992188 13 C 3.5992188 13 3.1992188 13.4 3.1992188 14 C 3.1992188 14.6 3.5992187 15 4.1992188 15 C 4.7992188 15 5.1992188 14.6 5.1992188 14 C 5.1992188 13.4 4.7992187 13 4.1992188 13 z', + 'hospital': 'M7,1C6.4,1,6,1.4,6,2v4H2C1.4,6,1,6.4,1,7v1 c0,0.6,0.4,1,1,1h4v4c0,0.6,0.4,1,1,1h1c0.6,0,1-0.4,1-1V9h4c0.6,0,1-0.4,1-1V7c0-0.6-0.4-1-1-1H9V2c0-0.6-0.4-1-1-1H7z', + 'ice-cream': 'M5.44,8.17a3.473,3.473,0,0,0,2-.63A3.5,3.5,0,0,0,9,8.14h.44L8,13.7a.5.5,0,0,1-.92,0Zm6-3.473a2,2,0,0,1-4,0h0a2,2,0,1,1-2-2h.12a2,2,0,1,1,3.75,0h.13A2,2,0,0,1,11.439,4.7Z', + 'lodging': 'M0.5,2.5C0.2,2.5,0,2.7,0,3v7.5v2C0,12.8,0.2,13,0.5,13S1,12.8,1,12.5V11h13v1.5 c0,0.3,0.2,0.5,0.5,0.5s0.5-0.2,0.5-0.5v-2c0-0.3-0.2-0.5-0.5-0.5H1V3C1,2.7,0.8,2.5,0.5,2.5z M3.5,3C2.7,3,2,3.7,2,4.5l0,0 C2,5.3,2.7,6,3.5,6l0,0C4.3,6,5,5.3,5,4.5l0,0C5,3.7,4.3,3,3.5,3L3.5,3z M7,4C5.5,4,5.5,5.5,5.5,5.5V7h-3C2.2,7,2,7.2,2,7.5v1 C2,8.8,2.2,9,2.5,9H6h9V6.5C15,4,12.5,4,12.5,4H7z', + 'marker': 'M7.5 1C5.42312 1 3 2.2883 3 5.56759C3 7.79276 6.46156 12.7117 7.5 14C8.42309 12.7117 12 7.90993 12 5.56759C12 2.2883 9.57688 1 7.5 1Z', + 'museum': 'M7.5,0L1,3.5V4h13V3.5L7.5,0z M2,5v5l-1,1.6V13h13v-1.4L13,10V5H2z M4,6h1v5.5H4V6z M7,6h1v5.5H7V6z M10,6h1 v5.5h-1V6z', + 'park': 'M14,5.75c0.0113-0.6863-0.3798-1.3159-1-1.61C12.9475,3.4906,12.4014,2.9926,11.75,3 c-0.0988,0.0079-0.1962,0.0281-0.29,0.06c-0.0607-0.66-0.6449-1.1458-1.3048-1.0851C9.8965,1.9987,9.6526,2.1058,9.46,2.28l0,0 c0-0.6904-0.5596-1.25-1.25-1.25S6.96,1.5896,6.96,2.28C6.96,2.28,7,2.3,7,2.33C6.4886,1.8913,5.7184,1.9503,5.2797,2.4618 C5.1316,2.6345,5.0347,2.8451,5,3.07C4.8417,3.0195,4.6761,2.9959,4.51,3C3.6816,2.9931,3.0044,3.659,2.9975,4.4874 C2.9958,4.6872,3.0341,4.8852,3.11,5.07C2.3175,5.2915,1.8546,6.1136,2.0761,6.9061C2.2163,7.4078,2.6083,7.7998,3.11,7.94 c0.2533,0.7829,1.0934,1.2123,1.8763,0.959C5.5216,8.7258,5.9137,8.2659,6,7.71C6.183,7.8691,6.4093,7.9701,6.65,8v5L5,14h5l-1.6-1 v-2c0.7381-0.8915,1.6915-1.5799,2.77-2c0.8012,0.1879,1.603-0.3092,1.7909-1.1103C12.9893,7.7686,13.0025,7.6444,13,7.52 c0.0029-0.0533,0.0029-0.1067,0-0.16C13.6202,7.0659,14.0113,6.4363,14,5.75z M8.4,10.26V6.82C8.6703,7.3007,9.1785,7.5987,9.73,7.6 h0.28c0.0156,0.4391,0.2242,0.849,0.57,1.12C9.7643,9.094,9.0251,9.6162,8.4,10.26z', + 'parking': 'M4 2V13H6V9H8.5C10.433 9 12 7.433 12 5.5C12 3.567 10.433 2 8.5 2H4ZM6 7V4H8.5C9.32843 4 10 4.67157 10 5.5C10 6.32843 9.32843 7 8.5 7H6Z', + 'pharmacy': 'M9.5,4l1.07-1.54c0.0599,0.0046,0.1201,0.0046,0.18,0c0.6904-0.0004,1.2497-0.5603,1.2494-1.2506 C11.999,0.519,11.4391-0.0404,10.7487-0.04C10.0584-0.0396,9.499,0.5203,9.4994,1.2106c0,0.0131,0.0002,0.0262,0.0006,0.0394 c0,0,0,0.07,0,0.1L7,4H9.5z M12,6V5H3v1l1.5,3.5L3,13v1h9v-1l-1-3.5L12,6z M10,10H8v2H7v-2H5V9h2V7h1v2h2V10z', + 'restaurant-pizza': 'M1.393 7.931.025 7.315A14.05 14.05 0 0 1 7.764-.001l.027.07.511 1.331-.003.001a12.613 12.613 0 0 0-6.906 6.53Zm10.963 4.96L2.42 8.414a11.604 11.604 0 0 1 6.354-6.009l.003-.001.817 2.122c-.028-.002-.052-.016-.081-.016a1.062 1.062 0 1 0 .764 1.793l2.413 6.273a.249.249 0 0 1-.334.316ZM7.426 7.52a.925.925 0 1 0-1.849 0 .925.925 0 0 0 1.849 0Zm3.017 2.03a.925.925 0 1 0-.07.353.924.924 0 0 0 .07-.354Z', + 'restaurant': 'M3.5,0l-1,5.5c-0.1464,0.805,1.7815,1.181,1.75,2L4,14c-0.0384,0.9993,1,1,1,1s1.0384-0.0007,1-1L5.75,7.5 c-0.0314-0.8176,1.7334-1.1808,1.75-2L6.5,0H6l0.25,4L5.5,4.5L5.25,0h-0.5L4.5,4.5L3.75,4L4,0H3.5z M12,0 c-0.7364,0-1.9642,0.6549-2.4551,1.6367C9.1358,2.3731,9,4.0182,9,5v2.5c0,0.8182,1.0909,1,1.5,1L10,14c-0.0905,0.9959,1,1,1,1 s1,0,1-1V0z', + 'shop': 'm13.33 5h-1.83l-.39-2.33c-.1601-.7182-.7017-1.2905-1.41-1.49-.3493-.1124-.7131-.173-1.08-.18h-2.24c-.3669.007-.7307.0676-1.08.18-.7083.1995-1.2499.7718-1.41 1.49l-.39 2.33h-1.83c-.2761-.0017-.5013.2208-.503.497-.0003.0519.0074.1035.023.153l1.88 6.3c.1964.6246.7753 1.0496 1.43 1.05h6c.651-.0047 1.2247-.4289 1.42-1.05l1.88-6.3c.0829-.2634-.0635-.5441-.3269-.627-.0463-.0146-.0945-.0223-.1431-.023zm-8.81 0 .36-2.17c.0807-.3625.3736-.6395.74-.7.2463-.0776.5019-.1213.76-.13h2.24c.2614.0078.5205.0515.77.13.3664.0605.6593.3375.74.7l.35 2.17h-6z', + 'theatre': 'M2,1c0,0-1,0-1,1v5.1582C1,8.8885,1.354,11,4.5,11H5V8L2.5,9c0,0,0-2.5,2.5-2.5V5 c0-0.7078,0.0868-1.3209,0.5-1.7754C5.8815,2.805,6.5046,1.9674,8.1562,2.7539L9,3.3027V2c0,0,0-1-1-1C7.2922,1,6.0224,2,5,2 S2.7865,1,2,1z M3,3c0.5523,0,1,0.4477,1,1S3.5523,5,3,5S2,4.5523,2,4S2.4477,3,3,3z M7,4c0,0-1,0-1,1v5c0,2,1,4,4,4s4-2,4-4V5 c0-1-1-1-1-1c-0.7078,0-1.9776,1-3,1S7.7865,4,7,4z M8,6c0.5523,0,1,0.4477,1,1S8.5523,8,8,8S7,7.5523,7,7S7.4477,6,8,6z M12,6 c0.5523,0,1,0.4477,1,1s-0.4477,1-1,1s-1-0.4477-1-1S11.4477,6,12,6z M7.5,10H10h2.5c0,0,0,2.5-2.5,2.5S7.5,10,7.5,10z' + }; + // Category words (Search Box poi_category, or a caller's category) to + // Maki names, checked in order, so "pizza restaurant" is a pizza. + var CATEGORY_ICONS = [ + ['pizza', 'restaurant-pizza'], ['coffee', 'cafe'], ['cafe', 'cafe'], + ['café', 'cafe'], ['bakery', 'bakery'], ['ice cream', 'ice-cream'], + ['brewery', 'beer'], ['beer', 'beer'], ['pub', 'beer'], + ['bar', 'bar'], ['nightclub', 'bar'], ['wine', 'bar'], + ['fast food', 'fast-food'], ['burger', 'fast-food'], + ['restaurant', 'restaurant'], ['food', 'restaurant'], ['diner', 'restaurant'], + ['hotel', 'lodging'], ['motel', 'lodging'], ['hostel', 'lodging'], ['lodging', 'lodging'], + ['grocery', 'grocery'], ['supermarket', 'grocery'], ['convenience', 'convenience'], + ['gas', 'fuel'], ['fuel', 'fuel'], ['parking', 'parking'], + ['hospital', 'hospital'], ['clinic', 'hospital'], ['pharmacy', 'pharmacy'], + ['park', 'park'], ['garden', 'park'], ['museum', 'museum'], ['gallery', 'museum'], + ['theater', 'theatre'], ['theatre', 'theatre'], ['cinema', 'cinema'], ['movie', 'cinema'], + ['bank', 'bank'], ['atm', 'bank'], + ['shop', 'shop'], ['store', 'shop'], ['boutique', 'shop'], + ['attraction', 'attraction'], ['landmark', 'attraction'] + ]; + + // The Maki name for a marker or card: its own maki when we have that + // icon, else one matched from its category, else the plain marker. + function placeIcon(maki, category) { + if (typeof maki === 'string' && MAKI.hasOwnProperty(maki)) return maki; + if (typeof category === 'string') { + var words = ' ' + category.toLowerCase().split(/[^a-zà-ÿ]+/).join(' ') + ' '; + for (var i = 0; i < CATEGORY_ICONS.length; i++) { + if (words.indexOf(' ' + CATEGORY_ICONS[i][0] + ' ') !== -1) return CATEGORY_ICONS[i][1]; + } + } + return 'marker'; + } + window.__placeIcon = placeIcon; + + // Only ever called with a key of MAKI, so the markup is all ours. + function iconSvg(name) { + var d = MAKI[MAKI.hasOwnProperty(name) ? name : 'marker']; + return ''; + } + + function buildPlaceElement(icon, color) { + var el = document.createElement('div'); + el.className = 'marker-badge place'; + // A caller's color (gray for "other results", say) tints the icon. + if (color) el.style.color = color; + el.innerHTML = iconSvg(icon); + return el; + } + + // Dark or white, whichever reads better on the badge color (WCAG + // contrast), so numbers stay legible on orange, green or gray pins. + var BADGE_DARK_TEXT = '#1a0f04'; + function badgeTextColor(color) { + var hex = typeof color === 'string' ? color.replace('#', '') : ''; + if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join(''); + if (!/^[0-9a-f]{6}/i.test(hex)) return '#fff'; + var lum = [0, 2, 4].map(function(i) { + var c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + var l = 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2]; + // Against white (L=1) vs. against the dark text (L about 0.0055). + return (1.05 / (l + 0.05)) >= ((l + 0.05) / 0.0555) ? '#fff' : BADGE_DARK_TEXT; + } + window.__badgeTextColor = badgeTextColor; + function buildBadgeElement(label, color) { var el = document.createElement('div'); el.className = 'marker-badge'; el.style.background = color; + el.style.color = badgeTextColor(color); el.textContent = label; return el; } @@ -520,38 +720,58 @@ ${initialDataScript} var color = m.color || (STYLE_DEFAULTS[style] && STYLE_DEFAULTS[style].color) || '#3b82f6'; var marker; - if (style === 'numbered' || style === 'start' || style === 'end') { + if (style === 'place') { + marker = new mapboxgl.Marker({ element: buildPlaceElement(placeIcon(m.maki, m.category), m.color) }); + } else if (style === 'numbered' || style === 'start' || style === 'end') { var label = m.label || (style === 'start' ? 'S' : style === 'end' ? 'E' : ''); marker = new mapboxgl.Marker({ element: buildBadgeElement(label, color) }); } else { marker = new mapboxgl.Marker({ color: color }); } marker.setLngLat(m.coordinates); - if (m.popup) marker.setPopup(new mapboxgl.Popup().setText(m.popup)); + // A place with an id gets a carousel card instead of a popup: clicking + // its pin selects the card (see selectPlace). + if (m.popup && !m.id) marker.setPopup(new mapboxgl.Popup().setText(m.popup)); marker.addTo(map); trackedMarkers.push(marker); // Tagged by buildSearchMiniPayload with the result's mapbox_id so the - // side panel (click-to-flyTo) and Place Details enrichment can look the - // marker back up later without walking trackedMarkers. - if (m.id) markersById[m.id] = { marker: marker, popupText: m.popup || '' }; + // carousel and Place Details enrichment can look the marker back up + // later without walking trackedMarkers. + if (m.id) { + var badgeEl = typeof marker.getElement === 'function' ? marker.getElement() : null; + markersById[m.id] = { marker: marker, el: badgeEl, color: color }; + if (badgeEl && m.name) { + var nameEl = document.createElement('span'); + nameEl.className = 'marker-name'; + nameEl.textContent = m.name; + badgeEl.appendChild(nameEl); + } + if (badgeEl && typeof badgeEl.addEventListener === 'function') { + var placeId = m.id; + badgeEl.addEventListener('click', function(e) { + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + selectPlace(placeId, { scrollCard: true, panTo: true }); + }); + } + } bbox.extend(m.coordinates); } - // Guarantees a marker that opts into the side panel (has .id) also gets - // a visible number on the map matching its panel row, unless the caller + // Guarantees a marker that opts into the carousel (has .id) also gets + // a visible number on the map matching its card, unless the caller // already gave it an explicit label. Without this, a caller that builds // plain unlabeled pins for POI markers (observed live: Claude did this // for inline markers, leaving the map showing generic blue pins with no - // way to tell which panel row is which) breaks the whole point of the - // panel numbering. Mutates markers in place, so it must run before + // way to tell which card is which) breaks the whole point of the + // numbering. Mutates markers in place, so it must run before // addOneMarker draws them. // // Numbering runs across the whole render, not per call: a render can // draw inline markers and then merge in self-fetched search results, and // each set arrives numbered from 1. The first set keeps the caller's // numbers when they're unique (a visit order, say); a later set, or one - // with repeated numbers, is renumbered to continue the panel's count. - // Letter labels ("A") are kept as they are. A place already in the panel + // with repeated numbers, is renumbered to continue the carousel's count. + // Letter labels ("A") are kept as they are. A place already in the carousel // this render is dropped rather than drawn and listed twice. Returns the // markers to draw. function applyPanelNumbering(markers) { @@ -578,12 +798,17 @@ ${initialDataScript} fresh.forEach(function(m) { if (!m || !m.id) return; if (m.label && (keep || !isWholeNumber(m.label))) return; + // A place with no number of its own gets an icon pin, not a number: + // the selected pin and card light up together, which is enough to + // tell them apart. + if (!m.label && m.style !== 'start' && m.style !== 'end') { + m.style = 'place'; + return; + } + // Otherwise a numbered pin, or a start/end pin, takes the next number. panelCount += 1; var n = String(panelCount); - if (!m.label) { - if (m.style !== 'start' && m.style !== 'end') m.style = 'numbered'; - if (!m.color) m.color = '#f97316'; - } else if (typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) { + if (m.label && typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) { // Search popups start with the old number ("3. Name"). m.popup = n + '. ' + m.popup.slice(String(m.label).length + 2); } @@ -598,26 +823,44 @@ ${initialDataScript} return n > 0 && String(n) === String(label); } - // Bottom padding that keeps fitted data out from under the legend - // (bottom-left), unless the map is too short to spare the room. + // The summary chip covers GL JS's top-left control corner, so move the + // zoom and layer buttons below it while it's shown. + function placeTopLeftControls() { + if (!map || typeof map.getContainer !== 'function') return; + var container = map.getContainer(); + var corner = container && typeof container.querySelector === 'function' + ? container.querySelector('.mapboxgl-ctrl-top-left') + : null; + if (!corner) return; + corner.style.top = summaryEl.style.display === 'block' + ? (summaryEl.offsetHeight + 8) + 'px' + : ''; + } + + // Bottom padding that keeps fitted data out from under the carousel and + // the legend above it, unless the map is too short to spare the room. function bottomPadding() { - if (legendEl.style.display !== 'block') return 50; - var bottom = (legendEl.offsetHeight || 0) + 24; + var bottom = 0; + if (carouselEl.style.display === 'block') bottom = carouselTop() + 12; + if (legendEl.style.display === 'block') { + bottom = Math.max(bottom, legendTop() + 12); + } var el = document.getElementById('map'); var height = (el && el.clientHeight) || window.innerHeight || 0; - if (bottom <= 50 || (height && height - bottom - 70 < 160)) return 50; + if (bottom <= 50 || (height && height - bottom - 70 < 120)) return 50; return bottom; } - // Right padding that keeps fitted data out from under an open panel. On - // a narrow map there's no room beside it, so fit as if it weren't there - // (the panel starts collapsed there anyway; see appendPanelRows). - function rightPadding() { - if (panelEl.style.display !== 'flex' || panelCollapsed) return 30; - var right = (panelEl.offsetWidth || 280) + 24; - var mapWidth = mapContainerWidth(); - if (mapWidth && mapWidth - right < 240) return 30; - return right; + // Distance from the bottom of the map to the top of the carousel. 36px + // keeps it clear of the Mapbox logo and attribution. + var CAROUSEL_BOTTOM = 36; + function carouselTop() { + return CAROUSEL_BOTTOM + (carouselEl.offsetHeight || 100); + } + + function legendTop() { + var bottom = parseInt(legendEl.style.bottom, 10) || 12; + return bottom + (legendEl.offsetHeight || 0); } function mapContainerWidth() { @@ -631,7 +874,7 @@ ${initialDataScript} setTimeout(function() { map.resize(); map.fitBounds(bounds, { - padding: { top: 70, bottom: bottomPadding(), left: 30, right: rightPadding() }, + padding: { top: 70, bottom: bottomPadding(), left: 30, right: 30 }, duration: 600 }); }, 60); @@ -652,8 +895,9 @@ ${initialDataScript} if (payload.summary && summaryEl.style.display === 'none') { summaryEl.textContent = payload.summary; summaryEl.style.display = 'block'; + placeTopLeftControls(); } - maybeShowSidePanel(markers, payload.summary); + maybeShowSidePanel(markers); var bounds = bbox.bounds(); if (bounds) fitToBounds(bounds); } @@ -698,8 +942,8 @@ ${initialDataScript} // Covers both inline markers (a caller-composed payload) and self-fetch // markers (merged in later via mergeAdditionalPayload) — see // maybeShowSidePanel below. Whichever path produced id-bearing POI - // markers gets the same results panel. - maybeShowSidePanel(markers, payload.summary); + // markers gets the same results carousel. + maybeShowSidePanel(markers); // Legend if (Array.isArray(payload.legend) && payload.legend.length > 0) { @@ -1403,7 +1647,7 @@ ${initialDataScript} var features = []; points.forEach(function(f, i) { var props = f.properties || {}; - var popupParts = [(i + 1) + '. ' + (props.name || 'Result')]; + var popupParts = [props.name || 'Result']; var addr = props.full_address || props.place_formatted; if (addr) popupParts.push(addr); if (typeof props.distance === 'number') { @@ -1412,16 +1656,15 @@ ${initialDataScript} var coords = f.geometry.coordinates; // id/name/category/distanceMeters are the same generic POI fields an // inline (hand-composed) marker can carry — see maybeShowSidePanel, - // which builds the results panel from either source identically. - // mapbox_id is missing on the rare result that lacks one; the panel + // which builds the results carousel from either source identically. + // mapbox_id is missing on the rare result that lacks one; the carousel // just skips that row rather than show an unclickable/unenrichable // entry. markers.push({ id: props.mapbox_id, coordinates: coords, - style: 'numbered', - label: String(i + 1), - color: '#f97316', + style: 'place', + maki: props.maki, popup: popupParts.join(' — '), name: props.name || ('Result ' + (i + 1)), category: Array.isArray(props.poi_category) ? props.poi_category[0] : undefined, @@ -1445,11 +1688,11 @@ ${initialDataScript} data: { type: 'FeatureCollection', features: features }, paint: { 'circle-radius': 6, - 'circle-color': '#f97316', - 'circle-opacity': 0.15, + 'circle-color': '#0f172a', + 'circle-opacity': 0.1, 'circle-stroke-width': 1, - 'circle-stroke-color': '#f97316', - 'circle-stroke-opacity': 0.4 + 'circle-stroke-color': '#0f172a', + 'circle-stroke-opacity': 0.25 } } ] : []; @@ -1457,27 +1700,27 @@ ${initialDataScript} return { summary: summary, layers: layers, markers: markers }; } - // --- Side panel: result list + Place Details enrichment ---------------- + // --- Results carousel: one card per place + Place Details enrichment --- // Triggered generically by maybeShowSidePanel for ANY marker set that // includes id-bearing entries — self-fetch search/category-search // results (via mergeAdditionalPayload) and hand-composed inline markers // from render_map_tool's own "markers" input (via renderBody) both funnel // through the same path. Renders the initial (Search Box-only, or - // whatever the caller already provided) list immediately so the panel - // never blocks on the slower enrichment call. sourceLabel is plain text - // shown as the panel heading (e.g. the query, category, or the payload's - // own summary). + // whatever the caller already provided) cards immediately so the + // carousel never blocks on the slower enrichment call. function derivePanelItems(markers) { if (!Array.isArray(markers)) return []; var items = []; markers.forEach(function(m) { if (!m || !m.id) return; - // The pin's own label, so a row reads the same as its pin ("A" too). + // The pin's own label, so a card reads the same as its pin ("A" too). + // A place pin has none, and neither does its card. var badge = isWholeNumber(m.label) ? parseInt(m.label, 10) - : (m.label ? String(m.label) : items.length + 1); + : (m.label ? String(m.label) : null); items.push({ id: m.id, number: badge, + icon: placeIcon(m.maki, m.category), name: m.name || ('Result ' + (items.length + 1)), category: m.category, distanceMeters: typeof m.distanceMeters === 'number' ? m.distanceMeters : undefined, @@ -1489,16 +1732,16 @@ ${initialDataScript} // Exposed so tests can exercise the pure derivation logic without a DOM. window.__derivePanelItems = derivePanelItems; - // Adds rows for places not yet in this render's panel and enriches just - // those. A response is applied only if no newer render has started since - // (panelGeneration), so a slow lookup can't write into a newer panel. - function maybeShowSidePanel(markers, summary) { + // Adds cards for places not yet in this render's carousel and enriches + // just those. A response is applied only if no newer render has started + // since (panelGeneration), so a slow lookup can't write into newer cards. + function maybeShowSidePanel(markers) { var items = derivePanelItems(markers).filter(function(it) { return !panelIds[it.id]; }); if (items.length === 0) return; items.forEach(function(it) { panelIds[it.id] = true; }); - appendPanelRows(items, summary || 'Results'); + appendCards(items); var generation = panelGeneration; fetchPlaceDetailsBatch( items.map(function(it) { return it.id; }), @@ -1509,40 +1752,112 @@ ${initialDataScript} }); } - // Keyed by mapbox_id -> { li, meta } elements, so enrichSidePanel can - // update a row directly once Place Details resolves, without re-parsing - // panelEl's markup back into a queryable DOM (innerHTML + querySelector + // Keyed by mapbox_id -> { li, meta, body } elements, so enrichSidePanel + // can update a card directly once Place Details resolves, without + // re-parsing markup back into a queryable DOM (innerHTML + querySelector // round-trips don't work in every MCP Apps host's iframe sandbox, and // building real nodes up front is simpler to reason about either way). - // Reset by clearPanel, same lifecycle as the panel itself. + // Reset by clearPanel, same lifecycle as the carousel itself. var panelRowsById = {}; // Bumped by teardown(), so responses from an earlier render are dropped. var panelGeneration = 0; // Id-bearing markers numbered so far this render, and the ids listed. var panelCount = 0; var panelIds = {}; - // The viewer's collapse choice, kept across renders; null until the first - // panel decides a default from the map's width. - var panelCollapsed = null; - var panelListEl = null; + var cardListEl = null; + var cardOrder = []; + var activePlaceId = null; + + // classList when there is one: GL JS adds its own classes to a marker's + // element, which assigning className would wipe out. + function setActiveClass(el, on) { + if (!el) return; + if (el.classList && typeof el.classList.toggle === 'function') { + el.classList.toggle('active', on); + return; + } + var names = String(el.className || '').split(' ').filter(function(c) { + return c && c !== 'active'; + }); + if (on) names.push('active'); + el.className = names.join(' '); + } + + // Highlights one place: its card gets a border and its pin inverts and + // shows the name. scrollCard brings the card into view (a pin was + // clicked); panTo moves the map when the pin is hidden (a card was + // picked). + function selectPlace(id, opts) { + var row = panelRowsById[id]; + if (!row) return; + opts = opts || {}; + if (activePlaceId !== id) { + if (activePlaceId) { + var prevRow = panelRowsById[activePlaceId]; + var prevPin = markersById[activePlaceId]; + if (prevRow) setActiveClass(prevRow.li, false); + if (prevPin && prevPin.el) { + setActiveClass(prevPin.el, false); + prevPin.el.style.zIndex = ''; + } + } + activePlaceId = id; + setActiveClass(row.li, true); + var pin = markersById[id]; + if (pin && pin.el) { + setActiveClass(pin.el, true); + pin.el.style.zIndex = '2'; + } + } + if (opts.scrollCard && typeof row.li.scrollIntoView === 'function') { + row.li.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + if (opts.panTo) panToIfHidden(id); + } + window.__selectPlace = selectPlace; - function buildPanelRow(it) { + // Moves the map only when the pin, or the name label under it, is off + // screen or under the carousel, so swiping through nearby results + // doesn't keep shifting it. + var PIN_LABEL_ROOM = 52; + function panToIfHidden(id) { + var entry = markersById[id]; + if (!entry || !map || typeof map.project !== 'function') return; + var lngLat = entry.marker.getLngLat(); + var p = map.project(lngLat); + var el = document.getElementById('map'); + var width = (el && el.clientWidth) || 0; + var height = (el && el.clientHeight) || 0; + if (!width || !height) return; + var visibleBottom = height - carouselTop() - PIN_LABEL_ROOM; + if (p.x >= 24 && p.x <= width - 24 && p.y >= 24 && p.y <= visibleBottom) return; + // Just far enough to bring it 24px inside the visible area, so the + // pins around it mostly stay in view. + var x = Math.min(Math.max(p.x, 48), width - 48); + var y = Math.min(Math.max(p.y, 48), Math.max(48, visibleBottom - 24)); + map.panBy([p.x - x, p.y - y], { duration: 500 }); + } + + function buildCard(it) { var li = document.createElement('li'); - li.className = 'panel-item'; + li.className = 'card'; - var badge = document.createElement('span'); - badge.className = 'panel-badge'; - badge.textContent = String(it.number); + var badge = null; + if (it.number !== null && it.number !== undefined) { + badge = document.createElement('span'); + badge.className = 'card-badge'; + badge.textContent = String(it.number); + } // Match the pin when the caller colored it (e.g. gray for "other // results" next to a legend); the default orange is in the CSS. - if (typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' && + if (badge && typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' && (/^#[0-9a-f]{3,8}$/i).test(it.color)) { badge.style.background = it.color; - badge.style.color = '#fff'; + badge.style.color = badgeTextColor(it.color); } var name = document.createElement('div'); - name.className = 'panel-name'; + name.className = 'card-name'; name.textContent = it.name; var metaParts = []; @@ -1551,67 +1866,139 @@ ${initialDataScript} metaParts.push(Math.round(it.distanceMeters) + ' m'); } var meta = document.createElement('div'); - meta.className = 'panel-meta'; + meta.className = 'card-meta'; meta.textContent = metaParts.join(' · '); var body = document.createElement('div'); + body.className = 'card-body'; body.appendChild(name); body.appendChild(meta); - li.appendChild(badge); + // Placeholder picture, replaced by the photo if one loads (see + // loadPanelPhoto). Shown first by CSS order. + var thumb = document.createElement('span'); + thumb.className = 'card-thumb empty'; + thumb.innerHTML = iconSvg(it.icon); + if (typeof thumb.setAttribute === 'function') { + thumb.setAttribute('role', 'img'); + thumb.setAttribute('aria-label', it.name); + } + if (badge) li.appendChild(badge); li.appendChild(body); + li.appendChild(thumb); li.addEventListener('click', function() { - var entry = markersById[it.id]; - if (entry) map.flyTo({ center: entry.marker.getLngLat(), zoom: 15, duration: 500 }); + selectPlace(it.id, { scrollCard: true, panTo: true }); }); - panelRowsById[it.id] = { li: li, meta: meta }; + panelRowsById[it.id] = { li: li, meta: meta, body: body, thumb: thumb }; return li; } function clearPanel() { - while (panelEl.firstChild) panelEl.removeChild(panelEl.firstChild); + while (carouselEl.firstChild) carouselEl.removeChild(carouselEl.firstChild); panelRowsById = {}; - panelListEl = null; - } - - function applyPanelCollapsed() { - panelEl.className = panelCollapsed ? 'collapsed' : ''; - } - - function togglePanel() { - panelCollapsed = !panelCollapsed; - applyPanelCollapsed(); - if (lastFitBounds) fitToBounds(lastFitBounds); + cardListEl = null; + cardOrder = []; + activePlaceId = null; + } + + // Swiping selects whichever card ends up in the middle. + var scrollTimer = null; + function onCardScroll() { + if (scrollTimer) clearTimeout(scrollTimer); + scrollTimer = setTimeout(function() { + scrollTimer = null; + if (!cardListEl) return; + var center = (cardListEl.scrollLeft || 0) + (cardListEl.clientWidth || 0) / 2; + var best = null; + var bestDist = Infinity; + cardOrder.forEach(function(id) { + var row = panelRowsById[id]; + if (!row) return; + var mid = (row.li.offsetLeft || 0) + (row.li.offsetWidth || 0) / 2; + var d = Math.abs(mid - center); + if (d < bestDist) { bestDist = d; best = id; } + }); + if (best && best !== activePlaceId) selectPlace(best, { panTo: true }); + }, 120); } - // Creates the panel on the render's first id-bearing markers, and adds - // rows to it for later ones (e.g. search results merged in after inline - // markers), so every numbered pin has a row. - function appendPanelRows(items, sourceLabel) { + // Creates the carousel on the render's first id-bearing markers, and adds + // cards for later ones (e.g. search results merged in after inline + // markers), so every numbered pin has a card. The first card starts + // selected. + function appendCards(items) { if (!Array.isArray(items) || items.length === 0) return; - if (!panelListEl) { - var head = document.createElement('div'); - head.className = 'panel-head'; - head.textContent = sourceLabel || 'Results'; - head.addEventListener('click', togglePanel); - - panelListEl = document.createElement('ul'); - panelListEl.className = 'panel-list'; - - panelEl.appendChild(head); - panelEl.appendChild(panelListEl); - // On a narrow map the open panel would cover most of it. - if (panelCollapsed === null) { - var width = mapContainerWidth(); - panelCollapsed = width > 0 && width < 560; + var created = false; + if (!cardListEl) { + cardListEl = document.createElement('ul'); + cardListEl.className = 'card-list'; + cardListEl.addEventListener('scroll', onCardScroll); + carouselEl.appendChild(cardListEl); + carouselEl.style.display = 'block'; + created = true; + } + var list = cardListEl; + items.forEach(function(it) { + list.appendChild(buildCard(it)); + cardOrder.push(it.id); + }); + // The legend (bottom-left) moves up above the cards. + legendEl.style.bottom = (carouselTop() + 8) + 'px'; + if (created) selectPlace(items[0].id); + } + + // Today's hours from a Places API opening_hours string, which uses OSM + // syntax: "Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00", "Mo 09:00-12:00,13:00-17:00", + // "Su off", "24/7". Returns null for anything else (holidays, months, week + // numbers), so a card shows no hours rather than wrong ones. "Today" is + // the viewer's day, which can differ from the place's near midnight when + // they're in different time zones. + var OSM_WEEK = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + var HOURS_RANGE = /^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$/; + function osmDaysInclude(days, day) { + var parts = days.split(','); + var hit = false; + for (var i = 0; i < parts.length; i++) { + var ends = parts[i].split('-'); + var a = OSM_WEEK.indexOf(ends[0]); + var b = ends.length === 2 ? OSM_WEEK.indexOf(ends[1]) : a; + if (a === -1 || b === -1 || ends.length > 2) return null; + var d = OSM_WEEK.indexOf(day); + if (a <= b ? (d >= a && d <= b) : (d >= a || d <= b)) hit = true; + } + return hit; + } + function hoursToday(spec, now) { + if (typeof spec !== 'string') return null; + var text = spec.trim(); + if (!text) return null; + if (text === '24/7') return 'Open 24 hours'; + var day = OSM_WEEK[(now.getDay() + 6) % 7]; + var result = null; + var rules = text.split(';'); + for (var i = 0; i < rules.length; i++) { + var rule = rules[i].trim(); + if (!rule) continue; + var space = rule.indexOf(' '); + if (space === -1) return null; + var applies = osmDaysInclude(rule.slice(0, space), day); + if (applies === null) return null; + var times = rule.slice(space + 1).trim(); + var ranges = times === 'off' || times === 'closed' ? [] : times.split(','); + for (var j = 0; j < ranges.length; j++) { + if (!HOURS_RANGE.test(ranges[j].trim())) return null; + } + // In OSM a later rule for the same day overrides an earlier one. + if (applies) { + result = ranges.length === 0 + ? 'Closed today' + : 'Today ' + ranges.map(function(r) { return r.trim().replace('-', '–'); }).join(', '); } - applyPanelCollapsed(); - panelEl.style.display = 'flex'; } - var list = panelListEl; - items.forEach(function(it) { list.appendChild(buildPanelRow(it)); }); + return result; } + window.__hoursToday = hoursToday; // Search Box results mix Mapbox-native POI ids (decode to // "urn:mbxpoi:") with OSM-sourced ones (decode to @@ -1637,7 +2024,7 @@ ${initialDataScript} // Places API Details endpoint — batch form. Mirrors the single-record // GET src/tools/place-details-tool/PlaceDetailsTool.ts calls server-side, - // but the panel needs details for every result at once, so this uses the + // but the carousel needs details for every result at once, so this uses the // POST /details/retrieve batch form (up to 100 ids) instead of one // request per pin. Docs: https://docs.mapbox.com/api/search/places/ // Like every other self-fetch call, this runs client-side against the @@ -1672,18 +2059,18 @@ ${initialDataScript} }); }; return post(compatibleIds.slice(0, 100), false) - // Enrichment is a nice-to-have on top of an already-rendered panel — + // Enrichment is a nice-to-have on top of already-rendered cards — // Public Preview quota (1,000 records/month) or a transient error // here should never surface as a map error, just leave the - // Search-Box-only rows as they are. + // Search-Box-only cards as they are. .catch(function() { return null; }); } // Place Details photos are hosted wherever each business's site hosts // them, so the in-chat CSP (mapAppCsp.ts) can't allow them. Inside a host, // the page asks the server for the photo (PlacePhotoResource, by mapbox_id) - // and shows it as a data: URL. A standalone page from export_map_html_tool - // has no host and no CSP, so it loads the photo URL directly. + // and shows it as a data: URL. A page with no host has no CSP of ours, so + // it loads the photo URL directly. var MAX_PANEL_PHOTOS = 10; var EMBEDDED = window.parent !== window; var PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; @@ -1691,17 +2078,16 @@ ${initialDataScript} function loadPanelPhoto(row, id, photoUrl) { var show = function(src) { - // A later render may have replaced the panel while this loaded. + // A later render may have replaced the carousel while this loaded. if (panelRowsById[id] !== row) return; - var thumb = document.createElement('span'); - thumb.className = 'panel-thumb'; - thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; - row.li.appendChild(thumb); + row.thumb.className = 'card-thumb'; + row.thumb.innerHTML = ''; + row.thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; }; if (!EMBEDDED || !hostAnswered) { - // No MCP host (a standalone or framed exported page), so no CSP of - // ours either. Load it first: some sites refuse hotlinked photos (403), which - // would leave an empty box in the row. + // No MCP host (a standalone or framed page), so no CSP of ours + // either. Load it first: some sites refuse hotlinked photos (403), + // which would leave an empty box on the card. if (photoUrl.indexOf('https://') !== 0 || typeof Image !== 'function') return; var img = new Image(); img.onload = function() { show(photoUrl); }; @@ -1726,23 +2112,26 @@ ${initialDataScript} detailsResult.results.forEach(function(place) { if (!place || !place.mapbox_id) return; var row = panelRowsById[place.mapbox_id]; - if (row) { - var photoUrl = place.photos && place.photos[0] && place.photos[0].url; - if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) { - photosRequested++; - loadPanelPhoto(row, place.mapbox_id, photoUrl); - } - if (place.score && typeof place.score.popularity === 'number') { - var extra = Math.round(place.score.popularity * 100) + '% popularity'; - row.meta.textContent = row.meta.textContent ? row.meta.textContent + ' · ' + extra : extra; - } + if (!row) return; + var photoUrl = place.photos && place.photos[0] && place.photos[0].url; + if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) { + photosRequested++; + loadPanelPhoto(row, place.mapbox_id, photoUrl); } - - var entry = markersById[place.mapbox_id]; - if (entry && place.phone) { - entry.marker.setPopup( - new mapboxgl.Popup().setText(entry.popupText + ' — ' + place.phone) - ); + if (place.score && typeof place.score.popularity === 'number') { + var extra = Math.round(place.score.popularity * 100) + '% popularity'; + row.meta.textContent = row.meta.textContent ? row.meta.textContent + ' · ' + extra : extra; + } + // Today's hours and the phone number on a second line. + var lineParts = []; + var hours = hoursToday(place.opening_hours, new Date()); + if (hours) lineParts.push(hours); + if (typeof place.phone === 'string' && place.phone) lineParts.push(place.phone); + if (lineParts.length > 0) { + var line = document.createElement('div'); + line.className = 'card-extra'; + line.textContent = lineParts.join(' · '); + row.body.appendChild(line); } }); } @@ -2058,7 +2447,7 @@ ${initialDataScript} // Mirrors the deleted server-side buildGroundLocationPayload (now // client-only since the map preview self-fetches): a dark origin pin plus - // numbered orange POI markers. Isochrone contours aren't drawn here either + // place pins for the POIs. Isochrone contours aren't drawn here either // — the original never rendered them, only a contour-minutes summary. function buildGroundLocationMiniPayload(place, longitude, latitude, pois) { var markers = [ @@ -2070,7 +2459,7 @@ ${initialDataScript} } ]; (pois || []).forEach(function(poi, i) { - var parts = [(i + 1) + '. ' + poi.name]; + var parts = [poi.name]; if (poi.address) parts.push(poi.address); if (typeof poi.distance_meters === 'number') { parts.push(Math.round(poi.distance_meters) + ' m'); @@ -2078,9 +2467,7 @@ ${initialDataScript} markers.push({ id: poi.id, coordinates: [poi.longitude, poi.latitude], - style: 'numbered', - label: String(i + 1), - color: '#f97316', + style: 'place', popup: parts.join(' — '), name: poi.name, category: poi.category, diff --git a/src/tools/render-map-tool/RenderMapTool.input.schema.ts b/src/tools/render-map-tool/RenderMapTool.input.schema.ts index e548104..4b2264d 100644 --- a/src/tools/render-map-tool/RenderMapTool.input.schema.ts +++ b/src/tools/render-map-tool/RenderMapTool.input.schema.ts @@ -67,25 +67,25 @@ const markerSchema = z.object({ .string() .optional() .describe( - 'A Mapbox POI\'s mapbox_id — e.g. the "Mapbox ID" field printed by category_search_tool/search_and_geocode_tool. Set this on a hand-composed POI marker to get the same auto-enriching results side panel (popularity score and photo when available, via one batched Place Details lookup) that a payload_refs-based search result gets automatically. Omit for non-POI markers (route endpoints, waypoints, etc.).' + 'A Mapbox POI\'s mapbox_id — e.g. the "Mapbox ID" field printed by category_search_tool/search_and_geocode_tool. Set this on a hand-composed POI marker to get the same auto-enriching results carousel card (popularity score, photo and today\'s hours when available, via one batched Place Details lookup) that a payload_refs-based search result gets automatically. Leave `style` and `label` unset for such a place: it is drawn as a white pin with an icon for its category, like search results. Set `style: "numbered"` with a `label` only when the number means something, such as a visit order. Omit `id` for non-POI markers (route endpoints, waypoints, etc.).' ), name: z .string() .optional() .describe( - 'Display name for the results panel row. Only used when `id` is also set; falls back to "Result N" if omitted.' + 'Display name for the results carousel card (and under its pin when selected). Only used when `id` is also set; falls back to "Result N" if omitted.' ), category: z .string() .optional() .describe( - 'Category shown in the results panel row (e.g. "Coffee Shop"). Only used when `id` is also set.' + 'Category shown on the results carousel card (e.g. "Coffee Shop"), also used to pick the pin\'s icon. Only used when `id` is also set.' ), distanceMeters: z .number() .optional() .describe( - 'Distance in meters, shown in the results panel row. Only used when `id` is also set.' + 'Distance in meters, shown on the results carousel card. Only used when `id` is also set.' ) }); @@ -181,7 +181,7 @@ export const RenderMapInputSchema = z.object({ .array(markerSchema) .optional() .describe( - "Inline point markers (start/end, numbered visits, POI pins, etc.). Use only for hand-composed payloads. For POI markers, set `id` to the place's mapbox_id to get an auto-enriching results panel (see markerSchema's `id` field) instead of just a pin." + "Inline point markers (start/end, numbered visits, POI pins, etc.). Use only for hand-composed payloads. For POI markers, set `id` to the place's mapbox_id to get an auto-enriching results carousel card (see markerSchema's `id` field) instead of just a pin." ), legend: z .array(legendEntrySchema) diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts index e3a5eeb..8ac664d 100644 --- a/src/tools/render-map-tool/RenderMapTool.ts +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -68,7 +68,7 @@ export class RenderMapTool extends BaseTool< 'hand-composed payloads from raw GeoJSON. For a hand-composed POI marker ' + '(e.g. built from category_search_tool/search_and_geocode_tool/' + "place_details_tool output), set `markers[].id` to the place's mapbox_id " + - '— this gets it the same auto-enriching results panel (popularity ' + + '— this gets it the same auto-enriching results carousel (popularity ' + 'score and photo) that a payload_refs-based search result gets ' + 'automatically, with no extra tool calls. ' + 'To restyle the base map itself (e.g. "make the water red", switch to night ' + @@ -77,7 +77,10 @@ export class RenderMapTool extends BaseTool< 'Every call is a fresh render — it does not accumulate state — so to ' + 'restyle an already-drawn map without losing its content, re-pass the ' + 'same `payload_refs`/`layers` alongside `baseMapConfig`/`baseStyle` rather ' + - 'than sending them alone.'; + 'than sending them alone. ' + + "After rendering places, don't list them again in your reply: the map's " + + 'cards already show each name, category, distance, hours and phone. ' + + 'Answer the question or add what the cards leave out instead.'; readonly annotations = { title: 'Render Map', diff --git a/src/utils/mapAppPayload.ts b/src/utils/mapAppPayload.ts index 20bcee3..4a132df 100644 --- a/src/utils/mapAppPayload.ts +++ b/src/utils/mapAppPayload.ts @@ -121,18 +121,21 @@ export interface MapAppMarker { * A Mapbox POI's `mapbox_id` (e.g. the "Mapbox ID" field printed by * category_search_tool/search_and_geocode_tool's text output, or a * self-fetched search result's own id). Setting this gets the marker a - * row in the map's results side panel, which the iframe then enriches + * card in the map's results carousel, which the iframe then enriches * with a popularity score and photo via a batched Place Details lookup * (see mapAppHtml.ts's maybeShowSidePanel/fetchPlaceDetailsBatch) — the - * same panel search/category-search results already get automatically, - * now also available for hand-composed markers. + * same card search/category-search results already get automatically, + * now also available for hand-composed markers. With no `style`/`label` + * of its own, such a marker is drawn as a white place pin with a Maki + * icon picked from `category` (self-fetched search results use the + * Maki name Search Box gives each result). */ id?: string; - /** Display name for the side panel row. Only used when `id` is also set; falls back to "Result N" if omitted. */ + /** Display name for the results carousel card. Only used when `id` is also set; falls back to "Result N" if omitted. */ name?: string; - /** Category shown in the side panel row (e.g. "Coffee Shop"). Only used when `id` is also set. */ + /** Category shown in the results carousel card (e.g. "Coffee Shop"), also used to pick the pin's icon. Only used when `id` is also set. */ category?: string; - /** Distance in meters, shown in the side panel row. Only used when `id` is also set. */ + /** Distance in meters, shown in the results carousel card. Only used when `id` is also set. */ distanceMeters?: number; } diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index b7b72ee..76ceb82 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -37,6 +37,10 @@ function loadScriptSandbox(options?: { topLevel?: boolean; /** Framed by a page that isn't an MCP host, so ui/initialize gets no answer. */ noHost?: boolean; + /** hostContext the host answers ui/initialize with. */ + hostContext?: Record; + /** The system's prefers-color-scheme: dark, when set. */ + systemDark?: boolean; }) { const html = renderMapAppHtml({ publicToken: 'pk.test-token' }); const scriptMatch = html.match( @@ -55,11 +59,12 @@ function loadScriptSandbox(options?: { // A minimal stand-in for a DOM Element — enough for the panel code's // createElement/appendChild/removeChild/addEventListener usage, without // pulling in jsdom for a hand-rolled vm sandbox. `__fireClick` is a - // test-only convenience (not a real DOM API) for simulating a panel row - // click without constructing a real Event object. + // test-only convenience (not a real DOM API) for simulating a card click + // without constructing a real Event object; `__fire` does the same for + // any event type (the carousel listens for scroll). function fakeElement() { const children: FakeElement[] = []; - const clickListeners: Array<() => void> = []; + const listeners: Record void>> = {}; const el: FakeElement = { style: {} as Record, textContent: '', @@ -77,10 +82,11 @@ function loadScriptSandbox(options?: { get firstChild() { return children[0]; }, - addEventListener: (type: string, cb: () => void) => { - if (type === 'click') clickListeners.push(cb); + addEventListener: (type: string, cb: (e?: unknown) => void) => { + (listeners[type] ??= []).push(cb); }, - __fireClick: () => clickListeners.forEach((cb) => cb()) + __fireClick: () => (listeners.click ?? []).forEach((cb) => cb({})), + __fire: (type: string) => (listeners[type] ?? []).forEach((cb) => cb({})) }; return el; } @@ -93,8 +99,14 @@ function loadScriptSandbox(options?: { appendChild: (child: FakeElement) => FakeElement; removeChild: (child: FakeElement) => FakeElement; readonly firstChild: FakeElement | undefined; - addEventListener: (type: string, cb: () => void) => void; + addEventListener: (type: string, cb: (e?: unknown) => void) => void; __fireClick: () => void; + __fire: (type: string) => void; + scrollLeft?: number; + clientWidth?: number; + offsetLeft?: number; + offsetWidth?: number; + scrollIntoView?: (opts: unknown) => void; } // The script fetches each element by id exactly once at load time and @@ -121,8 +133,13 @@ function loadScriptSandbox(options?: { const markerConstructorCalls: Array> = []; const setStyleCalls: string[] = []; const fitBoundsCalls: Array<{ padding: Record }> = []; + const topLeftCorner = fakeElement(); const fakeMapInstance = { addControl: () => {}, + getContainer: () => ({ + querySelector: (selector: string) => + selector === '.mapboxgl-ctrl-top-left' ? topLeftCorner : null + }), on: (event: string, cb: () => void) => { if (event === 'load') cb(); }, @@ -160,8 +177,12 @@ function loadScriptSandbox(options?: { addTo: () => fakeMarkerInstance, setPopup: () => fakeMarkerInstance, getLngLat: () => ({ lng: 1, lat: 2 }), + getElement: () => lastMarkerElement, remove: () => {} }; + // The element passed to the most recent `new mapboxgl.Marker()`, which + // getElement() returns: the script reads it right after constructing. + let lastMarkerElement: unknown; // Overridable per-test; self-fetch tests replace this with a vi.fn(). Must // forward `init` too — the Place Details batch call is a POST with a JSON @@ -175,6 +196,19 @@ function loadScriptSandbox(options?: { // is shared across every `new mapboxgl.Marker()` call). let lastPopupText: string | undefined; + // : the theme lands in its data-theme attribute and its style. + const rootEl = { + attributes: {} as Record, + cssVars: {} as Record, + setAttribute(name: string, value: string) { + this.attributes[name] = value; + }, + style: { + setProperty: (name: string, value: string) => { + rootEl.cssVars[name] = value; + } + } + }; const sandbox: Record = { window: { addEventListener: (event: string, cb: typeof messageListener) => { @@ -188,7 +222,8 @@ function loadScriptSandbox(options?: { }, document: { getElementById: getElementById, - createElement: () => fakeElement() + createElement: () => fakeElement(), + documentElement: rootEl }, mapboxgl: { accessToken: '', @@ -199,6 +234,7 @@ function loadScriptSandbox(options?: { NavigationControl: function NavigationControl() {}, Marker: function Marker(options?: Record) { markerConstructorCalls.push(options ?? {}); + lastMarkerElement = options?.element; return fakeMarkerInstance; }, Popup: function Popup() { @@ -222,6 +258,12 @@ function loadScriptSandbox(options?: { win.postMessage = (win.parent as { postMessage: unknown }).postMessage; win.parent = win; } + if (options?.systemDark !== undefined) { + (sandbox.window as Record).matchMedia = (q: string) => ({ + matches: q === '(prefers-color-scheme: dark)' && options.systemDark, + addEventListener: () => {} + }); + } if (options?.topLevel || options?.noHost) { // Loads any image whose URL doesn't contain "403"; never fires onerror. sandbox.Image = class { @@ -239,7 +281,13 @@ function loadScriptSandbox(options?: { const initId = postMessageCalls.find((m) => m.method === 'ui/initialize') ?.id as number | undefined; if (initId !== undefined && messageListener && !options?.noHost) { - messageListener({ data: { jsonrpc: '2.0', id: initId, result: {} } }); + messageListener({ + data: { + jsonrpc: '2.0', + id: initId, + result: options?.hostContext ? { hostContext: options.hostContext } : {} + } + }); } return { @@ -270,6 +318,16 @@ function loadScriptSandbox(options?: { fetchImpl = impl; }, getLastPopupText: () => lastPopupText, + rootEl, + sendHostContext: (params: Record) => { + messageListener?.({ + data: { + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params + } + }); + }, postMessageCalls, map: fakeMapInstance, marker: fakeMarkerInstance, @@ -278,9 +336,24 @@ function loadScriptSandbox(options?: { setStyleCalls, errorEl: elementsById.error, summaryEl: elementsById.summary, - sidePanelEl: getElementById('side-panel'), + topLeftCorner, + carouselEl: getElementById('carousel'), + legendEl: getElementById('legend'), mapEl: getElementById('map') as unknown as { clientWidth?: number }, fitBoundsCalls, + placeIcon: ( + sandbox.window as { + __placeIcon?: (maki: unknown, category: unknown) => string; + } + ).__placeIcon as (maki: unknown, category: unknown) => string, + badgeTextColor: ( + sandbox.window as { __badgeTextColor?: (color: unknown) => string } + ).__badgeTextColor as (color: unknown) => string, + hoursToday: ( + sandbox.window as { + __hoursToday?: (spec: unknown, now: Date) => string | null; + } + ).__hoursToday, derivePanelItems: ( sandbox.window as { __derivePanelItems?: (markers: unknown[]) => PanelItem[]; @@ -297,6 +370,70 @@ interface PanelItem { distanceMeters?: number; } +describe('mapAppHtml summary chip', () => { + it('moves the top-left controls below the summary chip while it shows', () => { + const sandbox = loadScriptSandbox(); + (sandbox.summaryEl as unknown as { offsetHeight: number }).offsetHeight = + 30; + const send = (payload: Record) => + sandbox.sendToolResult({ + structuredContent: { mapboxRender: { layers: [], ...payload } } + }); + + send({ summary: '6 results for "coffee"' }); + expect(sandbox.topLeftCorner.style.top).toBe('38px'); + + send({ markers: [{ coordinates: [-122.4, 37.8] }] }); + expect(sandbox.topLeftCorner.style.top).toBe(''); + }); +}); + +describe('mapAppHtml light and dark theme', () => { + it('follows the system when the host sends no theme', () => { + expect( + loadScriptSandbox({ systemDark: true }).rootEl.attributes['data-theme'] + ).toBe('dark'); + expect( + loadScriptSandbox({ systemDark: false }).rootEl.attributes['data-theme'] + ).toBe('light'); + }); + + it("uses the host's theme and style variables over the system's", async () => { + const { rootEl } = loadScriptSandbox({ + systemDark: true, + hostContext: { + theme: 'light', + styles: { + variables: { + '--color-background-primary': '#fafafa', + 'not-a-variable': 'red' + } + } + } + }); + await Promise.resolve(); + + expect(rootEl.attributes['data-theme']).toBe('light'); + expect(rootEl.cssVars).toEqual({ '--color-background-primary': '#fafafa' }); + }); + + it('switches when the host changes theme, ignoring other updates', async () => { + const { rootEl, sendHostContext } = loadScriptSandbox({ + hostContext: { theme: 'light' } + }); + + await Promise.resolve(); + expect(rootEl.attributes['data-theme']).toBe('light'); + + sendHostContext({ theme: 'dark' }); + expect(rootEl.attributes['data-theme']).toBe('dark'); + + sendHostContext({ displayMode: 'fullscreen' }); + sendHostContext({ theme: 'sepia' }); + expect(rootEl.attributes['data-theme']).toBe('dark'); + }); +}); + describe('mapAppHtml inline-payload-first tool-result handling', () => { it('renders directly from an inline payload without ever calling resources/read', () => { const { sendToolResult, postMessageCalls, map } = loadScriptSandbox(); @@ -1545,7 +1682,7 @@ describe('mapAppHtml optimization self-fetch', () => { describe('mapAppHtml ground location self-fetch', () => { it('shows and enriches the results panel for nearby POI results that carry a mapbox_id', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); const coffeeId = mockPoiId('four-barrel'); const fetchSpy = vi.fn(async (url: string) => { @@ -1614,9 +1751,10 @@ describe('mapAppHtml ground location self-fetch', () => { }); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(sidePanelEl.style.display).toBe('flex'); - const [badge, body] = sidePanelEl.children[1].children[0].children; - expect(badge.textContent).toBe('1'); + expect(carouselEl.style.display).toBe('block'); + // A place card has no number, just the place's icon until a photo loads. + const [body, thumb] = carouselEl.children[0].children[0].children; + expect(thumb.className).toBe('card-thumb empty'); expect(body.children[0].textContent).toBe('Four Barrel Coffee'); expect(body.children[1].textContent).toBe('cafe · 120 m · 65% popularity'); }); @@ -1801,7 +1939,7 @@ describe('mapAppHtml ground location self-fetch', () => { }); describe('mapAppHtml results side panel: derivePanelItems', () => { - it('keeps only markers with an id, preferring a numeric label as the panel number', () => { + it('keeps only markers with an id, using a label as the card number and picking an icon', () => { const { derivePanelItems } = loadScriptSandbox(); const items = derivePanelItems?.([ @@ -1815,19 +1953,20 @@ describe('mapAppHtml results side panel: derivePanelItems', () => { // No id (e.g. a route waypoint marker) — must be dropped, not just // rendered without enrichment. { label: '1', name: 'No id' }, - // No label — falls back to positional numbering among id-bearing markers. - { id: 'b', name: 'Beta' } + // No label: a place pin, so its card has no number either. + { id: 'b', name: 'Beta', maki: 'bar' } ]); expect(items).toEqual([ { id: 'a', number: 3, + icon: 'cafe', name: 'Alpha', category: 'Cafe', distanceMeters: 120 }, - { id: 'b', number: 2, name: 'Beta' } + { id: 'b', number: null, icon: 'bar', name: 'Beta' } ]); }); @@ -1844,11 +1983,9 @@ describe('mapAppHtml results side panel: derivePanelItems', () => { }); describe('mapAppHtml results side panel: self-fetch (category_search)', () => { - it('renders the panel from category-search self-fetch results, then enriches it via one batched Place Details call', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, map, getLastPopupText } = + it('renders a card per result from category-search self-fetch results, then enriches it via one batched Place Details call', async () => { + const { sendToolResult, setFetchImpl, carouselEl, getLastPopupText } = loadScriptSandbox(); - const flyToSpy = vi.fn(); - map.flyTo = flyToSpy; const fetchSpy = vi.fn(async (url: string, init?: unknown) => { if (String(url).includes('places/v1/details/retrieve')) { @@ -1868,7 +2005,8 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { mapbox_id: mockPoiId('poi-1'), photos: [{ url: 'https://example.com/photo.jpg' }], score: { popularity: 0.8 }, - phone: '+15551234567' + phone: '+15551234567', + opening_hours: '24/7' } ] }) @@ -1917,28 +2055,29 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // ground-location self-fetch test's tick count for the same reason. for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(sidePanelEl.style.display).toBe('flex'); - const list = sidePanelEl.children[1]; + expect(carouselEl.style.display).toBe('block'); + const list = carouselEl.children[0]; + expect(list.className).toBe('card-list'); expect(list.children).toHaveLength(1); - const [badge, body] = list.children[0].children; - const [name, meta] = body.children; + const card = list.children[0]; + const [body, thumb] = card.children; + const [name, meta, extra] = body.children; - expect(badge.textContent).toBe('1'); + expect(thumb.className).toBe('card-thumb empty'); expect(name.textContent).toBe('Cafe Reveille'); expect(meta.textContent).toBe('cafe · 120 m · 80% popularity'); - - // Clicking the row flies the map to that marker. - list.children[0].__fireClick(); - expect(flyToSpy).toHaveBeenCalledWith( - expect.objectContaining({ center: { lng: 1, lat: 2 } }) - ); - - // The enriched phone number reaches the marker's popup. - expect(getLastPopupText()).toBe('1. Cafe Reveille — 120 m — +15551234567'); + // Hours and phone go on a second line of the card. + expect(extra.className).toBe('card-extra'); + expect(extra.textContent).toBe('Open 24 hours · +15551234567'); + // The first card starts selected. + expect(card.className).toBe('card active'); + // A place with a card gets no popup on its pin (the search center + // marker, which has no id, still has one). + expect(getLastPopupText() ?? '').not.toContain('Cafe Reveille'); }); it('never renders a panel for markers without a mapbox_id (e.g. a directions route)', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ @@ -1979,11 +2118,11 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - expect(sidePanelEl.style.display).not.toBe('flex'); + expect(carouselEl.style.display).not.toBe('block'); }); it('leaves the initial Search-Box-only panel row intact when the Place Details call fails (e.g. quota exceeded)', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, errorEl } = + const { sendToolResult, setFetchImpl, carouselEl, errorEl } = loadScriptSandbox(); const fetchSpy = vi.fn(async (url: string) => { if (String(url).includes('places/v1/details/retrieve')) { @@ -2019,9 +2158,9 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(sidePanelEl.style.display).toBe('flex'); - const row = sidePanelEl.children[1].children[0]; - expect(row.children[0].className).toBe('panel-badge'); + expect(carouselEl.style.display).toBe('block'); + const row = carouselEl.children[0].children[0]; + expect(row.children[0].className).toBe('card-body'); expect(errorEl?.style.display).not.toBe('block'); }); @@ -2060,7 +2199,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // "urn:mbxpoi:mapbox-n408854" has the native prefix but not a UUID, and // the batch endpoint 422'd the whole request for it. it('filters out urn:mbxpoi ids that are not UUIDs', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); const badId = Buffer.from('urn:mbxpoi:mapbox-n408854').toString('base64'); const goodId = mockPoiId('good'); const bodies: unknown[] = []; @@ -2081,12 +2220,12 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { for (let i = 0; i < 20; i++) await Promise.resolve(); expect(bodies).toEqual([{ ids: [goodId] }]); - const rows = sidePanelEl.children[1].children; - expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); + const rows = carouselEl.children[0].children; + expect(rows[1].children[0].children[1].textContent).toBe('50% popularity'); }); it('drops the id a 422 names and retries the batch once', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); // Passes the client-side filter, but the API rejects it anyway. const rejectedId = mockPoiId('rejected'); const goodId = mockPoiId('good'); @@ -2118,8 +2257,8 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { for (let i = 0; i < 30; i++) await Promise.resolve(); expect(bodies.map((b) => b.ids)).toEqual([[rejectedId, goodId], [goodId]]); - const rows = sidePanelEl.children[1].children; - expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); + const rows = carouselEl.children[0].children; + expect(rows[1].children[0].children[1].textContent).toBe('50% popularity'); }); it('filters out OSM-sourced ids before the batch call, so one incompatible id does not zero out enrichment for the rest', async () => { @@ -2129,7 +2268,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // batch endpoint 422s the ENTIRE request if even one id isn't its // native "urn:mbxpoi:" scheme — silently zeroing out enrichment // for all 10, not just the 2 bad ones, before this filter existed. - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); const osmId = Buffer.from('urn:mbxpoi-osm:n2678573672').toString('base64'); const nativeId = mockPoiId('poi-native'); @@ -2180,16 +2319,16 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { for (let i = 0; i < 20; i++) await Promise.resolve(); // Both rows still render — only enrichment eligibility differs. - const rows = sidePanelEl.children[1].children; + const rows = carouselEl.children[0].children; expect(rows).toHaveLength(2); - expect(rows[0].children[1].children[0].textContent).toBe('OSM Cafe'); + expect(rows[0].children[0].children[0].textContent).toBe('OSM Cafe'); // Never enriched (filtered out before the batch call). - expect(rows[0].children[1].children[1].textContent).not.toContain( + expect(rows[0].children[0].children[1].textContent).not.toContain( 'popularity' ); - expect(rows[1].children[1].children[0].textContent).toBe('Native Cafe'); - expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); + expect(rows[1].children[0].children[0].textContent).toBe('Native Cafe'); + expect(rows[1].children[0].children[1].textContent).toBe('50% popularity'); }); // Place Details photos are hosted on third-party CDNs (e.g. @@ -2240,7 +2379,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { const { sendToolResult, setFetchImpl, - sidePanelEl, + carouselEl, postMessageCalls, resolveResourcesRead } = loadScriptSandbox(); @@ -2255,28 +2394,30 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(reads[0].params).toEqual({ uri: 'mapbox://place-photo/' + encodeURIComponent(id) }); - // The CDN URL is never used directly. - const row = sidePanelEl.children[1].children[0]; - expect(row.children).toHaveLength(2); + // The CDN URL is never used directly. Until the photo arrives the + // card shows the placeholder, labeled with the place's name. + const row = carouselEl.children[0].children[0]; + expect(row.children[1].className).toBe('card-thumb empty'); resolveResourcesRead({ contents: [{ uri: 'x', mimeType: 'image/jpeg', blob: 'QUJD' }] }); for (let i = 0; i < 5; i++) await Promise.resolve(); - // At the end of the row, so badge and name stay where they were. - const [badge, body, thumb] = row.children; - expect(thumb.className).toBe('panel-thumb'); + // The placeholder becomes the photo, and its icon goes. + expect(row.children).toHaveLength(2); + const [body, thumb] = row.children; + expect(thumb.className).toBe('card-thumb'); + expect(thumb.innerHTML).toBe(''); expect(thumb.style.backgroundImage).toBe( 'url("data:image/jpeg;base64,QUJD")' ); - expect(badge.textContent).toBe('1'); expect(body.children[1].textContent).toBe('42% popularity'); expect(JSON.stringify(row)).not.toContain('mktgcdn'); }); - it('adds no thumbnail when the server has no photo', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, resolveResourcesRead } = + it('keeps the placeholder when the server has no photo', async () => { + const { sendToolResult, setFetchImpl, carouselEl, resolveResourcesRead } = loadScriptSandbox(); setFetchImpl(photoFetch(mockPoiId('photo-poi'))); @@ -2287,11 +2428,13 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); for (let i = 0; i < 5; i++) await Promise.resolve(); - expect(sidePanelEl.children[1].children[0].children).toHaveLength(2); + const [, thumb] = carouselEl.children[0].children[0].children; + expect(thumb.className).toBe('card-thumb empty'); + expect(thumb.style.backgroundImage).toBeUndefined(); }); it('loads the photo URL directly on a standalone page, which has no host or CSP', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, postMessageCalls } = + const { sendToolResult, setFetchImpl, carouselEl, postMessageCalls } = loadScriptSandbox({ topLevel: true }); setFetchImpl(photoFetch(mockPoiId('photo-poi'))); @@ -2301,15 +2444,15 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(postMessageCalls.some((m) => m.method === 'resources/read')).toBe( false ); - const [, , thumb] = sidePanelEl.children[1].children[0].children; - expect(thumb.className).toBe('panel-thumb'); + const [, thumb] = carouselEl.children[0].children[0].children; + expect(thumb.className).toBe('card-thumb'); expect(thumb.style.backgroundImage).toBe( 'url("https://dyn.mktgcdn.com/p/photo.jpg")' ); }); - it('adds no thumbnail on a standalone page when the photo fails to load', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox({ + it('keeps the placeholder on a standalone page when the photo fails to load', async () => { + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox({ topLevel: true }); const id = mockPoiId('photo-poi'); @@ -2332,13 +2475,14 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { sendToolResult(searchResult); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(sidePanelEl.children[1].children[0].children).toHaveLength(2); + const [, thumb] = carouselEl.children[0].children[0].children; + expect(thumb.className).toBe('card-thumb empty'); }); }); describe('mapAppHtml results side panel: inline markers', () => { it('renders and enriches the panel from inline markers that carry an id, with no selfFetch entries at all', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); const fetchSpy = vi.fn(async () => ({ ok: true, json: async () => ({ results: [] }) @@ -2369,11 +2513,8 @@ describe('mapAppHtml results side panel: inline markers', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - expect(sidePanelEl.style.display).toBe('flex'); - expect(sidePanelEl.children[0].textContent).toBe( - 'Coffee shops near Herndon, VA' - ); - const list = sidePanelEl.children[1]; + expect(carouselEl.style.display).toBe('block'); + const list = carouselEl.children[0]; expect(list.children).toHaveLength(1); const [badge, body] = list.children[0].children; expect(badge.textContent).toBe('1'); @@ -2386,18 +2527,14 @@ describe('mapAppHtml results side panel: inline markers', () => { ); }); - it('auto-promotes an id-bearing marker with no explicit style/label to a numbered badge, matching its panel row', async () => { + it('turns an id-bearing marker with no explicit style/label into a place pin, with no number on it or its card', async () => { // Observed live: Claude built inline POI markers as plain unlabeled // pins (default style, no label), so the map showed generic blue dots // with no way to tell which one corresponded to which panel row. The // panel's own numbering (positional, independent of what's drawn) // still worked, but nothing on the map matched it. - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ results: [] }) @@ -2425,29 +2562,25 @@ describe('mapAppHtml results side panel: inline markers', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - // Both markers got promoted to the numbered-badge element path - // (buildBadgeElement), not the default plain-color pin path. + // Both markers became place pins (buildPlaceElement), not the default + // plain-color pin path, and neither got a number. expect(markerConstructorCalls).toHaveLength(2); - const el1 = markerConstructorCalls[0].element as { - textContent: string; - style: Record; - }; - const el2 = markerConstructorCalls[1].element as { - textContent: string; - style: Record; - }; - expect(el1.textContent).toBe('1'); - expect(el1.style.background).toBe('#f97316'); - expect(el2.textContent).toBe('2'); + const els = markerConstructorCalls.map( + (c) => c.element as { className: string; innerHTML: string } + ); + for (const el of els) { + expect(el.className.split(' ')).toContain('place'); + expect(el.innerHTML).toContain(' { - const { sendToolResult, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, carouselEl } = loadScriptSandbox(); sendToolResult({ structuredContent: { @@ -2459,11 +2592,11 @@ describe('mapAppHtml results side panel: inline markers', () => { } }); - expect(sidePanelEl.style.display).not.toBe('flex'); + expect(carouselEl.style.display).not.toBe('block'); }); it("replaces the previous render's panel rather than accumulating rows across renders", async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ results: [] }) @@ -2487,7 +2620,7 @@ describe('mapAppHtml results side panel: inline markers', () => { } }); for (let i = 0; i < 6; i++) await Promise.resolve(); - expect(sidePanelEl.children[1].children).toHaveLength(1); + expect(carouselEl.children[0].children).toHaveLength(1); sendToolResult({ structuredContent: { @@ -2500,7 +2633,7 @@ describe('mapAppHtml results side panel: inline markers', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - const list = sidePanelEl.children[1]; + const list = carouselEl.children[0]; expect(list.children).toHaveLength(1); expect(list.children[0].children[1].children[0].textContent).toBe('Second'); }); @@ -2517,20 +2650,23 @@ describe('mapAppHtml results side panel: one panel per render', () => { .filter((t): t is string => typeof t === 'string' && t !== ''); const rowNames = (panel: { children: Array<{ children: unknown[] }> }) => ( - panel.children[1] as unknown as { + panel.children[0] as unknown as { children: Array<{ - children: Array<{ children: Array<{ textContent: string }> }>; + children: Array<{ + className: string; + children: Array<{ textContent: string }>; + }>; }>; } - ).children.map((row) => row.children[1].children[0].textContent); + ).children.map( + (row) => + row.children.find((c) => c.className === 'card-body')?.children[0] + .textContent + ); - it('numbers inline markers and merged search results as one list', async () => { - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + it('keeps inline numbers and adds merged search results as place pins, in one list', async () => { + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); const [a, b, c, d] = ['a', 'b', 'c', 'd'].map((x) => mockPoiId('one-list-' + x) ); @@ -2586,8 +2722,17 @@ describe('mapAppHtml results side panel: one panel per render', () => { }); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(labelsDrawn(markerConstructorCalls)).toEqual(['1', '2', '3', '4']); - expect(rowNames(sidePanelEl)).toEqual(['A', 'B', 'C', 'D']); + // The caller's numbered pins keep their numbers; the search results + // that join them are place pins (the search center is a plain pin). + expect(labelsDrawn(markerConstructorCalls)).toEqual(['1', '2']); + expect( + markerConstructorCalls.filter((c) => + (c.element as { className?: string } | undefined)?.className + ?.split(' ') + .includes('place') + ) + ).toHaveLength(2); + expect(rowNames(carouselEl)).toEqual(['A', 'B', 'C', 'D']); // Each place is looked up once. expect(detailsBodies).toEqual([ [a, b], @@ -2596,7 +2741,7 @@ describe('mapAppHtml results side panel: one panel per render', () => { }); it('ignores a Place Details response from an earlier render', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); const id = mockPoiId('stale'); const pending: Array<(v: unknown) => void> = []; setFetchImpl( @@ -2634,17 +2779,17 @@ describe('mapAppHtml results side panel: one panel per render', () => { for (let i = 0; i < 20; i++) await Promise.resolve(); const row = ( - sidePanelEl.children[1] as unknown as { + carouselEl.children[0] as unknown as { children: Array<{ children: Array<{ children: Array<{ textContent: string }> }>; }>; } ).children[0]; - expect(row.children[1].children[1].textContent).toBe('45% popularity'); + expect(row.children[0].children[1].textContent).toBe('45% popularity'); }); - it('fits the map beside an open panel, and refits when it is collapsed', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, fitBoundsCalls } = + it('fits the map above the carousel, and moves the legend up above it', async () => { + const { sendToolResult, setFetchImpl, fitBoundsCalls } = loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, @@ -2656,6 +2801,7 @@ describe('mapAppHtml results side panel: one panel per render', () => { mapboxRender: { ref: 'mapbox://inline/x', layers: [], + legend: [{ label: 'Cafes', color: '#f97316' }], markers: [ { coordinates: at(-122.41), id: mockPoiId('fit-1'), name: 'One' }, { coordinates: at(-122.42), id: mockPoiId('fit-2'), name: 'Two' } @@ -2664,50 +2810,46 @@ describe('mapAppHtml results side panel: one panel per render', () => { } }); await new Promise((r) => setTimeout(r, 80)); - // 280px panel + 24px gap. - expect(fitBoundsCalls.at(-1)?.padding.right).toBe(304); - - ( - sidePanelEl.children[0] as unknown as { __fireClick: () => void } - ).__fireClick(); - expect(sidePanelEl.className).toBe('collapsed'); - await new Promise((r) => setTimeout(r, 80)); - expect(fitBoundsCalls.at(-1)?.padding.right).toBe(30); + // The legend sits 8px above the carousel (36px above the logo + 100px + // before layout), and the fit leaves 12px above that. + expect(fitBoundsCalls.at(-1)?.padding).toEqual({ + top: 70, + bottom: 156, + left: 30, + right: 30 + }); }); - it('starts collapsed on a narrow map', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, mapEl, fitBoundsCalls } = - loadScriptSandbox(); - mapEl.clientWidth = 400; + it('puts the legend above the carousel, and back when a render has no cards', async () => { + const { sendToolResult, setFetchImpl, legendEl } = loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ results: [] }) })); - - sendToolResult({ - structuredContent: { - mapboxRender: { - ref: 'mapbox://inline/x', - layers: [], - markers: [ - { - coordinates: at(-122.41), - id: mockPoiId('narrow-1'), - name: 'One' - }, - { coordinates: at(-122.42), id: mockPoiId('narrow-2'), name: 'Two' } - ] + const render = (markers: Array>) => + sendToolResult({ + structuredContent: { + mapboxRender: { + ref: 'mapbox://inline/x', + layers: [], + legend: [{ label: 'Cafes', color: '#f97316' }], + markers + } } - } - }); - await new Promise((r) => setTimeout(r, 80)); + }); + + render([{ coordinates: at(-122.41), id: mockPoiId('lg-1'), name: 'One' }]); + for (let i = 0; i < 6; i++) await Promise.resolve(); + expect(legendEl.style.bottom).toBe('144px'); - expect(sidePanelEl.className).toBe('collapsed'); - expect(fitBoundsCalls.at(-1)?.padding.right).toBe(30); + render([{ coordinates: at(-122.41), name: 'No id' }]); + for (let i = 0; i < 6; i++) await Promise.resolve(); + expect(legendEl.style.bottom).toBe(''); }); - it("colors a row's badge like its pin when the caller set a color", async () => { - const { sendToolResult, setFetchImpl, sidePanelEl } = loadScriptSandbox(); + it("tints a place pin's icon with the caller's color", async () => { + const { sendToolResult, setFetchImpl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ results: [] }) @@ -2729,12 +2871,6 @@ describe('mapAppHtml results side panel: one panel per render', () => { coordinates: at(-122.42), id: mockPoiId('color-2'), name: 'Default' - }, - { - coordinates: at(-122.43), - id: mockPoiId('color-3'), - name: 'Bad', - color: 'red;x:y' } ] } @@ -2742,12 +2878,10 @@ describe('mapAppHtml results side panel: one panel per render', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - const badges = ( - sidePanelEl.children[1] as unknown as { - children: Array<{ children: Array<{ style: Record }> }>; - } - ).children.map((row) => row.children[0].style.background); - expect(badges).toEqual(['#6b7280', undefined, undefined]); + const colors = markerConstructorCalls.map( + (c) => (c.element as { style: Record }).style.color + ); + expect(colors).toEqual(['#6b7280', undefined]); }); }); @@ -2755,14 +2889,25 @@ describe('mapAppHtml results side panel: caller labels', () => { const at = (lng: number) => [lng, 37.78] as [number, number]; const badges = (panel: { children: unknown[] }) => ( - panel.children[1] as { - children: Array<{ children: Array<{ textContent: string }> }>; + panel.children[0] as { + children: Array<{ + children: Array<{ className: string; textContent: string }>; + }>; } - ).children.map((row) => row.children[0].textContent); - const labelsDrawn = (calls: Array>) => - calls.map( - (c) => (c.element as { textContent?: string } | undefined)?.textContent + ).children.map( + (row) => + row.children.find((c) => c.className === 'card-badge')?.textContent ); + // A numbered pin's label, or undefined for a place pin (icon only). + const labelsDrawn = (calls: Array>) => + calls.map((c) => { + const el = c.element as + | { className?: string; textContent?: string } + | undefined; + return el?.className?.split(' ').includes('place') + ? undefined + : el?.textContent; + }); const render = (markers: Array>) => ({ structuredContent: { mapboxRender: { ref: 'mapbox://inline/x', layers: [], markers } @@ -2774,12 +2919,8 @@ describe('mapAppHtml results side panel: caller labels', () => { }); it("keeps the caller's own numbers when they're unique, such as a visit order", async () => { - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(noDetails); sendToolResult( render( @@ -2795,16 +2936,12 @@ describe('mapAppHtml results side panel: caller labels', () => { for (let i = 0; i < 10; i++) await Promise.resolve(); expect(labelsDrawn(markerConstructorCalls)).toEqual(['3', '1', '2']); - expect(badges(sidePanelEl)).toEqual(['3', '1', '2']); + expect(badges(carouselEl)).toEqual(['3', '1', '2']); }); it('renumbers repeated numbers so each pin matches one row', async () => { - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(noDetails); sendToolResult( render( @@ -2820,16 +2957,12 @@ describe('mapAppHtml results side panel: caller labels', () => { for (let i = 0; i < 10; i++) await Promise.resolve(); expect(labelsDrawn(markerConstructorCalls)).toEqual(['1', '2', '3']); - expect(badges(sidePanelEl)).toEqual(['1', '2', '3']); + expect(badges(carouselEl)).toEqual(['1', '2', '3']); }); - it('shows a letter label in the panel row, as on its pin', async () => { - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + it('shows a letter label on the card, as on its pin', async () => { + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(noDetails); sendToolResult( render([ @@ -2845,14 +2978,15 @@ describe('mapAppHtml results side panel: caller labels', () => { ); for (let i = 0; i < 10; i++) await Promise.resolve(); - expect(labelsDrawn(markerConstructorCalls)).toEqual(['A', '1']); - expect(badges(sidePanelEl)).toEqual(['A', '1']); + // The unlabeled place is a place pin, with no number on its card. + expect(labelsDrawn(markerConstructorCalls)).toEqual(['A', undefined]); + expect(badges(carouselEl)).toEqual(['A', undefined]); }); }); describe('mapAppHtml results side panel: photos without an MCP host', () => { it('loads photos directly when framed by a page that never answers ui/initialize', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, postMessageCalls } = + const { sendToolResult, setFetchImpl, carouselEl, postMessageCalls } = loadScriptSandbox({ noHost: true }); const id = mockPoiId('framed-photo'); setFetchImpl(async (url: string) => { @@ -2884,15 +3018,210 @@ describe('mapAppHtml results side panel: photos without an MCP host', () => { false ); const row = ( - sidePanelEl.children[1] as { + carouselEl.children[0] as { children: Array<{ children: Array<{ className: string; style: Record }>; }>; } ).children[0]; - expect(row.children[2].className).toBe('panel-thumb'); - expect(row.children[2].style.backgroundImage).toBe( + expect(row.children[1].className).toBe('card-thumb'); + expect(row.children[1].style.backgroundImage).toBe( 'url("https://example.com/a.jpg")' ); }); }); + +describe('mapAppHtml results carousel: selecting a place', () => { + const at = (lng: number) => [lng, 37.78] as [number, number]; + type El = { + className: string; + style: Record; + children: El[]; + __fireClick: () => void; + __fire: (type: string) => void; + scrollLeft?: number; + clientWidth?: number; + offsetLeft?: number; + offsetWidth?: number; + scrollIntoView?: (opts: unknown) => void; + }; + async function threePlaces() { + const sandbox = loadScriptSandbox(); + sandbox.setFetchImpl(async () => ({ + ok: true, + json: async () => ({ results: [] }) + })); + sandbox.sendToolResult({ + structuredContent: { + mapboxRender: { + ref: 'mapbox://inline/x', + layers: [], + markers: ['One', 'Two', 'Three'].map((name, i) => ({ + coordinates: at(-122.41 - i / 100), + id: mockPoiId('select-' + i), + name + })) + } + } + }); + for (let i = 0; i < 6; i++) await Promise.resolve(); + const cards = (sandbox.carouselEl.children[0] as unknown as El).children; + const pins = sandbox.markerConstructorCalls.map((c) => c.element as El); + return { ...sandbox, cards, pins }; + } + const active = (els: El[]) => + els.map((el) => el.className.split(' ').includes('active')); + + it('starts with the first card and its pin selected, with the name under the pin', async () => { + const { cards, pins } = await threePlaces(); + + expect(active(cards)).toEqual([true, false, false]); + expect(active(pins)).toEqual([true, false, false]); + expect(pins[0].className).toBe('marker-badge place active'); + expect(pins[0].style.zIndex).toBe('2'); + const label = pins[0].children[0] as unknown as { + className: string; + textContent: string; + }; + expect(label.className).toBe('marker-name'); + expect(label.textContent).toBe('One'); + }); + + it('selects a card and its pin when the card is clicked', async () => { + const { cards, pins } = await threePlaces(); + const scrolled = vi.fn(); + cards[2].scrollIntoView = scrolled; + + cards[2].__fireClick(); + + expect(active(cards)).toEqual([false, false, true]); + expect(active(pins)).toEqual([false, false, true]); + expect(pins[0].style.zIndex).toBe(''); + expect(scrolled).toHaveBeenCalledWith( + expect.objectContaining({ inline: 'center' }) + ); + }); + + it('scrolls to the card when its pin is clicked', async () => { + const { cards, pins } = await threePlaces(); + const scrolled = vi.fn(); + cards[1].scrollIntoView = scrolled; + + pins[1].__fireClick(); + + expect(active(cards)).toEqual([false, true, false]); + expect(active(pins)).toEqual([false, true, false]); + expect(scrolled).toHaveBeenCalledOnce(); + }); + + it('selects the card that ends up in the middle after a swipe', async () => { + const { carouselEl, cards, pins } = await threePlaces(); + const list = carouselEl.children[0] as unknown as El; + cards.forEach((card, i) => { + card.offsetLeft = 32 + i * 310; + card.offsetWidth = 300; + }); + list.clientWidth = 364; + list.scrollLeft = 310; + + list.__fire('scroll'); + await new Promise((r) => setTimeout(r, 150)); + + expect(active(cards)).toEqual([false, true, false]); + expect(active(pins)).toEqual([false, true, false]); + }); + + it('pans to a hidden pin when its card is picked, but not to a visible one', async () => { + const { cards, map, mapEl } = await threePlaces(); + const panBy = vi.fn(); + const m = map as unknown as Record; + m.panBy = panBy; + (mapEl as { clientHeight?: number }).clientHeight = 500; + mapEl.clientWidth = 600; + + // In view, above the carousel. + m.project = () => ({ x: 300, y: 200 }); + cards[1].__fireClick(); + expect(panBy).not.toHaveBeenCalled(); + + // Behind the carousel. + m.project = () => ({ x: 300, y: 450 }); + cards[2].__fireClick(); + // Carousel top is 364, so the pin goes to 364 - 52 - 24 = 288. + expect(panBy).toHaveBeenCalledWith([0, 162], { duration: 500 }); + }); + + it('pans when only the name label would sit behind the carousel', async () => { + const { cards, pins, map, mapEl } = await threePlaces(); + const panBy = vi.fn(); + const m = map as unknown as Record; + m.panBy = panBy; + (mapEl as { clientHeight?: number }).clientHeight = 500; + mapEl.clientWidth = 600; + + // The carousel starts at 500 - 136 = 364; the pin is above it, but + // its label isn't. + m.project = () => ({ x: 300, y: 330 }); + cards[1].__fireClick(); + expect(panBy).toHaveBeenCalledTimes(1); + + // Clicking a pin in the same spot pans too. + pins[2].__fireClick(); + expect(panBy).toHaveBeenCalledTimes(2); + }); +}); + +describe('mapAppHtml place pin icons', () => { + const { placeIcon } = loadScriptSandbox(); + + it.each([ + ['cafe', undefined, 'cafe'], // the result's own maki + ['rocket', 'Coffee Shop', 'cafe'], // a maki we don't carry falls to the category + [undefined, 'Pizza Restaurant', 'restaurant-pizza'], // checked before "restaurant" + [undefined, 'café', 'cafe'], + [undefined, 'Barber Shop', 'shop'], // whole words, so not "bar" + [undefined, 'Space Center', 'marker'], + [undefined, undefined, 'marker'] + ])('maki %s, category %s -> %s', (maki, category, icon) => { + expect(placeIcon(maki, category)).toBe(icon); + }); +}); + +describe('mapAppHtml badge text color', () => { + const { badgeTextColor } = loadScriptSandbox(); + + it.each([ + ['#f97316', '#1a0f04'], // default orange: white is only 2.8:1 + ['#22c55e', '#1a0f04'], // start green + ['#2563eb', '#fff'], // numbered blue + ['#ef4444', '#1a0f04'], // end red + ['#0f172a', '#fff'], + ['#fff', '#1a0f04'], + ['not-a-color', '#fff'] + ])('%s gets %s numbers', (bg, text) => { + expect(badgeTextColor(bg)).toBe(text); + }); +}); + +describe('mapAppHtml results carousel: hours today', () => { + const { hoursToday } = loadScriptSandbox(); + // 2026-10-19 is a Monday; 2026-10-25 a Sunday. + const monday = new Date(2026, 9, 19, 12); + const sunday = new Date(2026, 9, 25, 12); + + it.each([ + ['24/7', monday, 'Open 24 hours'], + ['Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00', monday, 'Today 07:00–19:00'], + ['Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00', sunday, 'Today 08:00–17:00'], + ['Mo 09:00-12:00,13:00-17:00', monday, 'Today 09:00–12:00, 13:00–17:00'], + ['Mo-Su 08:00-20:00; Su off', sunday, 'Closed today'], + ['Fr-Mo 10:00-22:00', monday, 'Today 10:00–22:00'], + ['Tu-Sa 10:00-18:00', monday, null], + ['Mo-Fr 07:00-19:00; PH off', monday, null], + ['Mo 9am-5pm', monday, null], + ['', monday, null], + [undefined, monday, null] + ])('%s', (spec, now, expected) => { + expect(hoursToday?.(spec, now)).toBe(expected); + }); +});