From f7b4c1394a8dba26b84b813b295ad9d2ae1809a6 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 19 Oct 2026 16:31:33 -0400 Subject: [PATCH 1/7] feat(render_map_tool): results carousel instead of the side list Place results now show as swipeable cards along the bottom of the map, like ChatGPT's place results. The selected card and its pin stay in sync: swiping or clicking a card highlights its pin (white, on top, with the name under it) and pans only when the pin is hidden; clicking a pin scrolls to its card. Pins with a card no longer open popups. Cards show today's hours parsed from simple OSM opening_hours. Closes #283 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + .../place-photo/PlacePhotoResource.ts | 4 +- src/resources/resourceRegistry.ts | 2 +- src/resources/ui-apps/mapAppHtml.ts | 476 ++++++++++++------ .../RenderMapTool.input.schema.ts | 10 +- src/tools/render-map-tool/RenderMapTool.ts | 2 +- src/utils/mapAppPayload.ts | 10 +- .../ui-apps/mapAppHtml.script.test.ts | 417 +++++++++------ 8 files changed, 618 insertions(+), 310 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541150..904d60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ### New Features +- **`render_map_tool`: results carousel at the bottom of the map instead of the side list (#283).** 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 number, 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. + - **Card and pin stay in sync:** the selected card gets a border, and its pin 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 moves the map only when its pin is off screen or behind the cards. + - **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. + - Tool and resource descriptions now say "carousel" instead of "panel". Numbering, 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..e26786c 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -85,51 +85,66 @@ export function renderMapAppHtml(params: { box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; } - #side-panel { - position: absolute; top: 12px; right: 12px; bottom: 12px; z-index: 10; - width: 280px; max-width: calc(100% - 24px); - background: rgba(15, 23, 42, 0.88); color: #f1f5f9; - border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.3); - display: flex; flex-direction: column; overflow: hidden; - } - #side-panel .panel-head { - padding: 10px 12px; font-size: 13px; font-weight: 600; - border-bottom: 1px solid rgba(255,255,255,0.12); flex: 0 0 auto; - display: flex; justify-content: space-between; gap: 8px; cursor: pointer; - } - /* Click the heading to collapse the list (see togglePanel). */ - #side-panel .panel-head::after { content: '▾'; opacity: 0.7; } - #side-panel.collapsed { bottom: auto; } - #side-panel.collapsed .panel-list { display: none; } - #side-panel.collapsed .panel-head { border-bottom: none; } - #side-panel.collapsed .panel-head::after { content: '▸'; } - #side-panel .panel-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; flex: 1; } - #side-panel .panel-item { - display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: 6px; cursor: pointer; - } - #side-panel .panel-item:hover { background: rgba(255,255,255,0.08); } - /* Only added once a photo has loaded (see loadPanelPhoto), at the end of - the row so names stay aligned whether or not a row has one. */ - #side-panel .panel-thumb { - flex: 0 0 auto; width: 40px; height: 40px; border-radius: 6px; margin-left: auto; + /* Selected place (see selectPlace): the pin inverts and shows its name, + like the card it matches. */ + .marker-badge.active { + background: #fff !important; color: #0f172a; + transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.5); + } + .marker-badge .marker-name { + display: none; position: absolute; top: 32px; left: 50%; transform: translateX(-50%); + white-space: nowrap; font-size: 11px; font-weight: 600; color: #0f172a; + background: rgba(255,255,255,0.92); padding: 1px 6px; border-radius: 4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); pointer-events: none; + } + .marker-badge.active .marker-name { display: block; } + /* Results carousel. Sits above the Mapbox logo and attribution, which + must stay visible; the legend moves up above it (see showCarousel). */ + #carousel { position: absolute; left: 0; right: 0; bottom: 36px; z-index: 10; } + #carousel .card-list { + list-style: none; margin: 0; padding: 4px 32px 6px; + display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden; + scroll-snap-type: x mandatory; scrollbar-width: none; + } + #carousel .card-list::-webkit-scrollbar { display: none; } + #carousel .card { + flex: 0 0 auto; width: 300px; max-width: calc(100vw - 64px); min-height: 84px; + scroll-snap-align: center; + display: flex; align-items: center; gap: 10px; padding: 10px; + background: rgba(15, 23, 42, 0.92); color: #f1f5f9; + border: 2px solid transparent; border-radius: 14px; + box-shadow: 0 2px 10px rgba(0,0,0,0.35); cursor: pointer; + } + #carousel .card.active { border-color: #f8fafc; } + /* Added once a photo loads (see loadPanelPhoto), after the text in the + DOM but shown first. */ + #carousel .card-thumb { + order: -1; flex: 0 0 auto; width: 64px; height: 64px; border-radius: 10px; background-size: cover; background-position: center; } - /* Visit-order badge, matching the numbered map marker. */ - #side-panel .panel-badge { - flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; margin-top: 1px; + /* Matches the numbered pin. */ + #carousel .card-badge { + flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; background: #f97316; color: #1a0f04; display: flex; align-items: center; justify-content: center; - font-size: 10px; font-weight: 700; + font-size: 11px; font-weight: 700; + } + #carousel .card-body { flex: 1 1 auto; min-width: 0; } + #carousel .card-name { + font-size: 14px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + #carousel .card-meta, #carousel .card-extra { + font-size: 12px; color: #cbd5e1; margin-top: 3px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - #side-panel .panel-name { font-size: 12.5px; font-weight: 600; } - #side-panel .panel-meta { font-size: 11px; color: #cbd5e1; margin-top: 2px; }
- +
Loading…
${initialDataScript} @@ -143,9 +158,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 = {}; @@ -439,7 +454,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; @@ -527,31 +543,49 @@ ${initialDataScript} 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 }); + }); + } + } 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) { @@ -598,26 +632,30 @@ ${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. + // 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 +669,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); @@ -653,7 +691,7 @@ ${initialDataScript} summaryEl.textContent = payload.summary; summaryEl.style.display = 'block'; } - maybeShowSidePanel(markers, payload.summary); + maybeShowSidePanel(markers); var bounds = bbox.bounds(); if (bounds) fitToBounds(bounds); } @@ -698,8 +736,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) { @@ -1412,8 +1450,8 @@ ${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({ @@ -1457,22 +1495,20 @@ ${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). var badge = isWholeNumber(m.label) ? parseInt(m.label, 10) : (m.label ? String(m.label) : items.length + 1); items.push({ @@ -1489,16 +1525,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,29 +1545,92 @@ ${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; + + // Moves the map only when the pin is off screen or under the carousel, + // so swiping through nearby results doesn't keep shifting it. + 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() - 8; + if (p.x >= 24 && p.x <= width - 24 && p.y >= 24 && p.y <= visibleBottom) return; + map.easeTo({ center: lngLat, offset: [0, -carouselTop() / 2], duration: 500 }); + } - function buildPanelRow(it) { + 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.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. @@ -1542,7 +1641,7 @@ ${initialDataScript} } var name = document.createElement('div'); - name.className = 'panel-name'; + name.className = 'card-name'; name.textContent = it.name; var metaParts = []; @@ -1551,67 +1650,129 @@ ${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); li.appendChild(body); 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 }; 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 +1798,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 +1833,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 +1852,17 @@ ${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.className = 'card-thumb'; thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; row.li.appendChild(thumb); }; 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 +1887,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); } }); } diff --git a/src/tools/render-map-tool/RenderMapTool.input.schema.ts b/src/tools/render-map-tool/RenderMapTool.input.schema.ts index e548104..bccce75 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. Omit 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"). 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..cd6f68d 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 ' + diff --git a/src/utils/mapAppPayload.ts b/src/utils/mapAppPayload.ts index 20bcee3..040084b 100644 --- a/src/utils/mapAppPayload.ts +++ b/src/utils/mapAppPayload.ts @@ -121,18 +121,18 @@ 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, + * same card search/category-search results already get automatically, * now also available for hand-composed markers. */ 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"). 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..cef80d9 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -55,11 +55,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 +78,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 +95,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 @@ -160,8 +168,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 @@ -199,6 +211,7 @@ function loadScriptSandbox(options?: { NavigationControl: function NavigationControl() {}, Marker: function Marker(options?: Record) { markerConstructorCalls.push(options ?? {}); + lastMarkerElement = options?.element; return fakeMarkerInstance; }, Popup: function Popup() { @@ -278,9 +291,15 @@ function loadScriptSandbox(options?: { setStyleCalls, errorEl: elementsById.error, summaryEl: elementsById.summary, - sidePanelEl: getElementById('side-panel'), + carouselEl: getElementById('carousel'), + legendEl: getElementById('legend'), mapEl: getElementById('map') as unknown as { clientWidth?: number }, fitBoundsCalls, + hoursToday: ( + sandbox.window as { + __hoursToday?: (spec: unknown, now: Date) => string | null; + } + ).__hoursToday, derivePanelItems: ( sandbox.window as { __derivePanelItems?: (markers: unknown[]) => PanelItem[]; @@ -1545,7 +1564,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,8 +1633,8 @@ 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(carouselEl.style.display).toBe('block'); + const [badge, body] = carouselEl.children[0].children[0].children; expect(badge.textContent).toBe('1'); expect(body.children[0].textContent).toBe('Four Barrel Coffee'); expect(body.children[1].textContent).toBe('cafe · 120 m · 65% popularity'); @@ -1844,11 +1863,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 +1885,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 +1935,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 [badge, body] = card.children; + const [name, meta, extra] = body.children; expect(badge.textContent).toBe('1'); 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 +1998,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 +2038,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-badge'); expect(errorEl?.style.display).not.toBe('block'); }); @@ -2060,7 +2079,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 +2100,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; + const rows = carouselEl.children[0].children; expect(rows[1].children[1].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,7 +2137,7 @@ 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; + const rows = carouselEl.children[0].children; expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); }); @@ -2129,7 +2148,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,7 +2199,7 @@ 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'); // Never enriched (filtered out before the batch call). @@ -2240,7 +2259,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { const { sendToolResult, setFetchImpl, - sidePanelEl, + carouselEl, postMessageCalls, resolveResourcesRead } = loadScriptSandbox(); @@ -2256,7 +2275,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { uri: 'mapbox://place-photo/' + encodeURIComponent(id) }); // The CDN URL is never used directly. - const row = sidePanelEl.children[1].children[0]; + const row = carouselEl.children[0].children[0]; expect(row.children).toHaveLength(2); resolveResourcesRead({ @@ -2266,7 +2285,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // 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'); + expect(thumb.className).toBe('card-thumb'); expect(thumb.style.backgroundImage).toBe( 'url("data:image/jpeg;base64,QUJD")' ); @@ -2276,7 +2295,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); it('adds no thumbnail when the server has no photo', async () => { - const { sendToolResult, setFetchImpl, sidePanelEl, resolveResourcesRead } = + const { sendToolResult, setFetchImpl, carouselEl, resolveResourcesRead } = loadScriptSandbox(); setFetchImpl(photoFetch(mockPoiId('photo-poi'))); @@ -2287,11 +2306,11 @@ 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); + expect(carouselEl.children[0].children[0].children).toHaveLength(2); }); 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 +2320,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({ + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox({ topLevel: true }); const id = mockPoiId('photo-poi'); @@ -2332,13 +2351,13 @@ 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); + expect(carouselEl.children[0].children[0].children).toHaveLength(2); }); }); 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 +2388,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'); @@ -2392,12 +2408,8 @@ describe('mapAppHtml results side panel: inline markers', () => { // 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: [] }) @@ -2441,13 +2453,13 @@ describe('mapAppHtml results side panel: inline markers', () => { expect(el2.textContent).toBe('2'); // And the panel's badges show the exact same numbers. - const rows = sidePanelEl.children[1].children; + const rows = carouselEl.children[0].children; expect(rows[0].children[0].textContent).toBe('1'); expect(rows[1].children[0].textContent).toBe('2'); }); it('never renders a panel when no inline marker carries an id', () => { - const { sendToolResult, sidePanelEl } = loadScriptSandbox(); + const { sendToolResult, carouselEl } = loadScriptSandbox(); sendToolResult({ structuredContent: { @@ -2459,11 +2471,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 +2499,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 +2512,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,7 +2529,7 @@ 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 }> }>; }>; @@ -2525,12 +2537,8 @@ describe('mapAppHtml results side panel: one panel per render', () => { ).children.map((row) => row.children[1].children[0].textContent); it('numbers inline markers and merged search results as one list', async () => { - const { - sendToolResult, - setFetchImpl, - sidePanelEl, - markerConstructorCalls - } = loadScriptSandbox(); + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); const [a, b, c, d] = ['a', 'b', 'c', 'd'].map((x) => mockPoiId('one-list-' + x) ); @@ -2587,7 +2595,7 @@ 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']); + expect(rowNames(carouselEl)).toEqual(['A', 'B', 'C', 'D']); // Each place is looked up once. expect(detailsBodies).toEqual([ [a, b], @@ -2596,7 +2604,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,7 +2642,7 @@ 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 }> }>; }>; @@ -2643,8 +2651,8 @@ describe('mapAppHtml results side panel: one panel per render', () => { expect(row.children[1].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 +2664,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 +2673,45 @@ 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)); + }); - expect(sidePanelEl.className).toBe('collapsed'); - expect(fitBoundsCalls.at(-1)?.padding.right).toBe(30); + 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'); + + 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(); + const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox(); setFetchImpl(async () => ({ ok: true, json: async () => ({ results: [] }) @@ -2743,7 +2747,7 @@ 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 { + carouselEl.children[0] as unknown as { children: Array<{ children: Array<{ style: Record }> }>; } ).children.map((row) => row.children[0].style.background); @@ -2755,7 +2759,7 @@ 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 { + panel.children[0] as { children: Array<{ children: Array<{ textContent: string }> }>; } ).children.map((row) => row.children[0].textContent); @@ -2774,12 +2778,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 +2795,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 +2816,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(); + const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = + loadScriptSandbox(); setFetchImpl(noDetails); sendToolResult( render([ @@ -2846,13 +2838,13 @@ 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']); + expect(badges(carouselEl)).toEqual(['A', '1']); }); }); 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 +2876,160 @@ 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].className).toBe('card-thumb'); expect(row.children[2].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 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 easeTo = vi.fn(); + const m = map as unknown as Record; + m.easeTo = easeTo; + (mapEl as { clientHeight?: number }).clientHeight = 500; + mapEl.clientWidth = 600; + + // In view, above the carousel. + m.project = () => ({ x: 300, y: 200 }); + cards[1].__fireClick(); + expect(easeTo).not.toHaveBeenCalled(); + + // Behind the carousel. + m.project = () => ({ x: 300, y: 450 }); + cards[2].__fireClick(); + expect(easeTo).toHaveBeenCalledWith( + expect.objectContaining({ center: { lng: 1, lat: 2 } }) + ); + }); +}); + +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); + }); +}); From bb862999aca1a0282510226ca9cd09d23ec07667 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 19 Oct 2026 16:31:55 -0400 Subject: [PATCH 2/7] docs: CHANGELOG PR number for the carousel Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904d60f..d9fd6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New Features -- **`render_map_tool`: results carousel at the bottom of the map instead of the side list (#283).** 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 number, 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. +- **`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 number, 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. - **Card and pin stay in sync:** the selected card gets a border, and its pin 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 moves the map only when its pin is off screen or behind the cards. - **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. From 675a181a1516964bc22de6457d14230d071e1faa Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 10:22:54 -0400 Subject: [PATCH 3/7] fix(render_map_tool): keep the selected pin's label clear of the cards Found in a headless render: a pin just above the carousel kept its name label hidden behind the cards. Pan when the label (not just the pin) would be hidden, do it for pin clicks too, and pan only as far as needed instead of recentering, so nearby pins stay in view. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/resources/ui-apps/mapAppHtml.ts | 16 ++++++---- .../ui-apps/mapAppHtml.script.test.ts | 30 +++++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fd6bb..acf3339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### 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 number, 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. - - **Card and pin stay in sync:** the selected card gets a border, and its pin 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 moves the map only when its pin is off screen or behind the cards. + - **Card and pin stay in sync:** the selected card gets a border, and its pin 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. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index e26786c..d509d7c 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -564,7 +564,7 @@ ${initialDataScript} var placeId = m.id; badgeEl.addEventListener('click', function(e) { if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); - selectPlace(placeId, { scrollCard: true }); + selectPlace(placeId, { scrollCard: true, panTo: true }); }); } } @@ -1609,8 +1609,10 @@ ${initialDataScript} } window.__selectPlace = selectPlace; - // Moves the map only when the pin is off screen or under the carousel, - // so swiping through nearby results doesn't keep shifting 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; @@ -1620,9 +1622,13 @@ ${initialDataScript} var width = (el && el.clientWidth) || 0; var height = (el && el.clientHeight) || 0; if (!width || !height) return; - var visibleBottom = height - carouselTop() - 8; + var visibleBottom = height - carouselTop() - PIN_LABEL_ROOM; if (p.x >= 24 && p.x <= width - 24 && p.y >= 24 && p.y <= visibleBottom) return; - map.easeTo({ center: lngLat, offset: [0, -carouselTop() / 2], duration: 500 }); + // 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) { diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index cef80d9..e94538f 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -2991,23 +2991,41 @@ describe('mapAppHtml results carousel: selecting a place', () => { 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 easeTo = vi.fn(); + const panBy = vi.fn(); const m = map as unknown as Record; - m.easeTo = easeTo; + 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(easeTo).not.toHaveBeenCalled(); + expect(panBy).not.toHaveBeenCalled(); // Behind the carousel. m.project = () => ({ x: 300, y: 450 }); cards[2].__fireClick(); - expect(easeTo).toHaveBeenCalledWith( - expect.objectContaining({ center: { lng: 1, lat: 2 } }) - ); + // 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); }); }); From 35e9eed7e352f7688c3b52a81ebfa47e762f73a7 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 10:31:18 -0400 Subject: [PATCH 4/7] fix(render_map_tool): move the zoom buttons below the summary chip The "6 results for ..." chip sat on top of the zoom-in button. Same placeTopLeftControls fix as #278, copied as-is so the two merge cleanly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/resources/ui-apps/mapAppHtml.ts | 16 +++++++++++++ .../ui-apps/mapAppHtml.script.test.ts | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acf3339..64bd29e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Card and pin stay in sync:** the selected card gets a border, and its pin 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. + - **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). - Tool and resource descriptions now say "carousel" instead of "panel". Numbering, 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`). diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index d509d7c..5a3ca96 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -430,6 +430,7 @@ ${initialDataScript} } else { summaryEl.style.display = 'none'; } + placeTopLeftControls(); if (!map) { loadingEl.style.display = 'none'; return; } if (mapLoaded) render(payload); else pendingPayload = payload; @@ -632,6 +633,20 @@ ${initialDataScript} return n > 0 && String(n) === String(label); } + // 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() { @@ -690,6 +705,7 @@ ${initialDataScript} if (payload.summary && summaryEl.style.display === 'none') { summaryEl.textContent = payload.summary; summaryEl.style.display = 'block'; + placeTopLeftControls(); } maybeShowSidePanel(markers); var bounds = bbox.bounds(); diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index e94538f..5c89451 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -129,8 +129,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(); }, @@ -291,6 +296,7 @@ function loadScriptSandbox(options?: { setStyleCalls, errorEl: elementsById.error, summaryEl: elementsById.summary, + topLeftCorner, carouselEl: getElementById('carousel'), legendEl: getElementById('legend'), mapEl: getElementById('map') as unknown as { clientWidth?: number }, @@ -316,6 +322,24 @@ 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 inline-payload-first tool-result handling', () => { it('renders directly from an inline payload without ever calling resources/read', () => { const { sendToolResult, postMessageCalls, map } = loadScriptSandbox(); From 791ace0546023a3e46e3e5ca280e0ca1af86c242 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 15:46:33 -0400 Subject: [PATCH 5/7] feat(render_map_tool): follow OpenAI's UI guidelines for the carousel - Every card has a picture: Assembly's marker icon on a muted tile until a photo loads, or when there is none, labeled for screen readers. - Pin numbers pick dark or white text by WCAG contrast; white on the default orange was 2.8:1. - The tool description asks the model not to repeat the places the cards already show. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/resources/ui-apps/mapAppHtml.ts | 44 ++++++++++++++++--- src/tools/render-map-tool/RenderMapTool.ts | 5 ++- .../ui-apps/mapAppHtml.script.test.ts | 38 +++++++++++++--- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bd29e..38b47d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **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 (Assembly's marker icon on a muted tile until a photo loads, or when there is none), labeled with the place's name for screen readers. Pin numbers are dark or white, whichever has more contrast on the pin's color, since white on the default orange was 2.8:1, below WCAG AA. The tool description asks the model not to list the places again after rendering, since the cards already show them. - Tool and resource descriptions now say "carousel" instead of "panel". Numbering, 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`). diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 5a3ca96..f368d83 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -88,7 +88,7 @@ export function renderMapAppHtml(params: { /* Selected place (see selectPlace): the pin inverts and shows its name, like the card it matches. */ .marker-badge.active { - background: #fff !important; color: #0f172a; + background: #fff !important; color: #0f172a !important; transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.5); } .marker-badge .marker-name { @@ -122,6 +122,11 @@ export function renderMapAppHtml(params: { order: -1; flex: 0 0 auto; width: 64px; height: 64px; border-radius: 10px; background-size: cover; background-position: center; } + /* Until a photo loads, or when the place has none: Assembly's marker + icon on a muted tile, so every card has a picture. */ + #carousel .card-thumb.empty { + background: #1e293b url("data:image/svg+xml;utf8,") center / 28px no-repeat; + } /* Matches the numbered pin. */ #carousel .card-badge { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; @@ -492,10 +497,28 @@ ${initialDataScript} end: { color: '#ef4444' } }; + // 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; } @@ -1659,7 +1682,7 @@ ${initialDataScript} if (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'); @@ -1679,14 +1702,23 @@ ${initialDataScript} body.className = 'card-body'; body.appendChild(name); body.appendChild(meta); + // 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'; + if (typeof thumb.setAttribute === 'function') { + thumb.setAttribute('role', 'img'); + thumb.setAttribute('aria-label', it.name); + } li.appendChild(badge); li.appendChild(body); + li.appendChild(thumb); li.addEventListener('click', function() { selectPlace(it.id, { scrollCard: true, panTo: true }); }); - panelRowsById[it.id] = { li: li, meta: meta, body: body }; + panelRowsById[it.id] = { li: li, meta: meta, body: body, thumb: thumb }; return li; } @@ -1876,10 +1908,8 @@ ${initialDataScript} var show = function(src) { // A later render may have replaced the carousel while this loaded. if (panelRowsById[id] !== row) return; - var thumb = document.createElement('span'); - thumb.className = 'card-thumb'; - thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; - row.li.appendChild(thumb); + row.thumb.className = 'card-thumb'; + row.thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; }; if (!EMBEDDED || !hostAnswered) { // No MCP host (a standalone or framed page), so no CSP of ours diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts index cd6f68d..8ac664d 100644 --- a/src/tools/render-map-tool/RenderMapTool.ts +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -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/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index 5c89451..1d1c2b9 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -301,6 +301,9 @@ function loadScriptSandbox(options?: { legendEl: getElementById('legend'), mapEl: getElementById('map') as unknown as { clientWidth?: number }, fitBoundsCalls, + badgeTextColor: ( + sandbox.window as { __badgeTextColor?: (color: unknown) => string } + ).__badgeTextColor as (color: unknown) => string, hoursToday: ( sandbox.window as { __hoursToday?: (spec: unknown, now: Date) => string | null; @@ -2298,16 +2301,18 @@ 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. + // 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).toHaveLength(2); + expect(row.children[2].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. + // The placeholder becomes the photo; badge and name stay where they were. + expect(row.children).toHaveLength(3); const [badge, body, thumb] = row.children; expect(thumb.className).toBe('card-thumb'); expect(thumb.style.backgroundImage).toBe( @@ -2318,7 +2323,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(JSON.stringify(row)).not.toContain('mktgcdn'); }); - it('adds no thumbnail when the server has no photo', async () => { + it('keeps the placeholder when the server has no photo', async () => { const { sendToolResult, setFetchImpl, carouselEl, resolveResourcesRead } = loadScriptSandbox(); setFetchImpl(photoFetch(mockPoiId('photo-poi'))); @@ -2330,7 +2335,9 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); for (let i = 0; i < 5; i++) await Promise.resolve(); - expect(carouselEl.children[0].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 () => { @@ -2351,7 +2358,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { ); }); - it('adds no thumbnail on a standalone page when the photo fails to load', async () => { + it('keeps the placeholder on a standalone page when the photo fails to load', async () => { const { sendToolResult, setFetchImpl, carouselEl } = loadScriptSandbox({ topLevel: true }); @@ -2375,7 +2382,8 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { sendToolResult(searchResult); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(carouselEl.children[0].children[0].children).toHaveLength(2); + const [, , thumb] = carouselEl.children[0].children[0].children; + expect(thumb.className).toBe('card-thumb empty'); }); }); @@ -3053,6 +3061,22 @@ describe('mapAppHtml results carousel: selecting a place', () => { }); }); +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. From 9bba6636232180c7224f99ee7d4c5a2b40632ba8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 16:00:23 -0400 Subject: [PATCH 6/7] feat(render_map_tool): white place pins with Maki icons instead of orange numbers Matches ChatGPT's place results and OpenAI's UI guidance: search results and other places in the carousel are white pins with a Maki icon for their category, black when selected, and their cards drop the number badge. The icon comes from Search Box's maki name, else the category, else a plain marker; about 20 common Maki icons (CC0) are inlined. Numbered badges stay where the number means something: a caller's numbered label (visit order), optimized stops, start/end. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +- src/resources/ui-apps/mapAppHtml.ts | 155 ++++++++++++--- .../RenderMapTool.input.schema.ts | 4 +- src/utils/mapAppPayload.ts | 7 +- .../ui-apps/mapAppHtml.script.test.ts | 180 +++++++++++------- 5 files changed, 250 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b47d7..52887b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ ### 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 number, 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. - - **Card and pin stay in sync:** the selected card gets a border, and its pin 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. +- **`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 (Assembly's marker icon on a muted tile until a photo loads, or when there is none), labeled with the place's name for screen readers. Pin numbers are dark or white, whichever has more contrast on the pin's color, since white on the default orange was 2.8:1, below WCAG AA. The tool description asks the model not to list the places again after rendering, since the cards already show them. - - Tool and resource descriptions now say "carousel" instead of "panel". Numbering, dedupe across merged payloads, the batched Place Details lookup and photo loading are unchanged. + - **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. + - 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. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index f368d83..34bbe75 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -98,6 +98,17 @@ export function renderMapAppHtml(params: { box-shadow: 0 1px 3px rgba(0,0,0,0.3); pointer-events: none; } .marker-badge.active .marker-name { display: block; } + /* A place pin (search results and other places in the carousel): a + white disc with the place's Maki icon, black when selected. Numbered + badges stay for markers whose number means something (visit order). */ + .marker-badge.place { + width: 30px; height: 30px; background: #fff; color: #0f172a; + border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.35); + } + .marker-badge.place svg { width: 16px; height: 16px; display: block; } + .marker-badge.place.active { + background: #0f172a !important; color: #fff !important; + } /* Results carousel. Sits above the Mapbox logo and attribution, which must stay visible; the legend moves up above it (see showCarousel). */ #carousel { position: absolute; left: 0; right: 0; bottom: 36px; z-index: 10; } @@ -122,11 +133,13 @@ export function renderMapAppHtml(params: { order: -1; flex: 0 0 auto; width: 64px; height: 64px; border-radius: 10px; background-size: cover; background-position: center; } - /* Until a photo loads, or when the place has none: Assembly's marker - icon on a muted tile, so every card has a picture. */ + /* Until a photo loads, or when the place has none: the place's icon + on a muted tile, so every card has a picture. */ #carousel .card-thumb.empty { - background: #1e293b url("data:image/svg+xml;utf8,") center / 28px no-repeat; + background: #1e293b; color: #94a3b8; + display: flex; align-items: center; justify-content: center; } + #carousel .card-thumb.empty svg { width: 28px; height: 28px; display: block; } /* Matches the numbered pin. */ #carousel .card-badge { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; @@ -497,6 +510,83 @@ ${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'; @@ -560,7 +650,9 @@ ${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 { @@ -636,12 +728,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); } @@ -1480,7 +1577,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') { @@ -1496,9 +1593,8 @@ ${initialDataScript} 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, @@ -1522,11 +1618,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 } } ] : []; @@ -1548,11 +1644,13 @@ ${initialDataScript} markers.forEach(function(m) { if (!m || !m.id) return; // 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, @@ -1674,12 +1772,15 @@ ${initialDataScript} var li = document.createElement('li'); li.className = 'card'; - var badge = document.createElement('span'); - badge.className = 'card-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 = badgeTextColor(it.color); @@ -1706,11 +1807,12 @@ ${initialDataScript} // 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); } - li.appendChild(badge); + if (badge) li.appendChild(badge); li.appendChild(body); li.appendChild(thumb); @@ -1909,6 +2011,7 @@ ${initialDataScript} // A later render may have replaced the carousel while this loaded. if (panelRowsById[id] !== row) return; row.thumb.className = 'card-thumb'; + row.thumb.innerHTML = ''; row.thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")'; }; if (!EMBEDDED || !hostAnswered) { @@ -2274,7 +2377,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 = [ @@ -2286,7 +2389,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'); @@ -2294,9 +2397,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 bccce75..4b2264d 100644 --- a/src/tools/render-map-tool/RenderMapTool.input.schema.ts +++ b/src/tools/render-map-tool/RenderMapTool.input.schema.ts @@ -67,7 +67,7 @@ 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 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. 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() @@ -79,7 +79,7 @@ const markerSchema = z.object({ .string() .optional() .describe( - 'Category shown on the results carousel card (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() diff --git a/src/utils/mapAppPayload.ts b/src/utils/mapAppPayload.ts index 040084b..4a132df 100644 --- a/src/utils/mapAppPayload.ts +++ b/src/utils/mapAppPayload.ts @@ -125,12 +125,15 @@ export interface MapAppMarker { * with a popularity score and photo via a batched Place Details lookup * (see mapAppHtml.ts's maybeShowSidePanel/fetchPlaceDetailsBatch) — the * same card search/category-search results already get automatically, - * now also available for hand-composed markers. + * 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 results carousel card. Only used when `id` is also set; falls back to "Result N" if omitted. */ name?: string; - /** Category shown in the results carousel card (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 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 1d1c2b9..d37a431 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -301,6 +301,11 @@ function loadScriptSandbox(options?: { 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, @@ -1661,8 +1666,9 @@ describe('mapAppHtml ground location self-fetch', () => { for (let i = 0; i < 20; i++) await Promise.resolve(); expect(carouselEl.style.display).toBe('block'); - const [badge, body] = carouselEl.children[0].children[0].children; - expect(badge.textContent).toBe('1'); + // 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'); }); @@ -1847,7 +1853,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?.([ @@ -1861,19 +1867,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' } ]); }); @@ -1967,10 +1974,10 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(list.className).toBe('card-list'); expect(list.children).toHaveLength(1); const card = list.children[0]; - const [badge, body] = card.children; + 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'); // Hours and phone go on a second line of the card. @@ -2067,7 +2074,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(carouselEl.style.display).toBe('block'); const row = carouselEl.children[0].children[0]; - expect(row.children[0].className).toBe('card-badge'); + expect(row.children[0].className).toBe('card-body'); expect(errorEl?.style.display).not.toBe('block'); }); @@ -2128,7 +2135,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(bodies).toEqual([{ ids: [goodId] }]); const rows = carouselEl.children[0].children; - expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); + expect(rows[1].children[0].children[1].textContent).toBe('50% popularity'); }); it('drops the id a 422 names and retries the batch once', async () => { @@ -2165,7 +2172,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(bodies.map((b) => b.ids)).toEqual([[rejectedId, goodId], [goodId]]); const rows = carouselEl.children[0].children; - expect(rows[1].children[1].children[1].textContent).toBe('50% popularity'); + 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 () => { @@ -2228,14 +2235,14 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // Both rows still render — only enrichment eligibility differs. 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. @@ -2304,21 +2311,21 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { // 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[2].className).toBe('card-thumb empty'); + 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(); - // The placeholder becomes the photo; badge and name stay where they were. - expect(row.children).toHaveLength(3); - const [badge, body, thumb] = row.children; + // 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'); }); @@ -2335,7 +2342,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { }); for (let i = 0; i < 5; i++) await Promise.resolve(); - const [, , thumb] = carouselEl.children[0].children[0].children; + const [, thumb] = carouselEl.children[0].children[0].children; expect(thumb.className).toBe('card-thumb empty'); expect(thumb.style.backgroundImage).toBeUndefined(); }); @@ -2351,7 +2358,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { expect(postMessageCalls.some((m) => m.method === 'resources/read')).toBe( false ); - const [, , thumb] = carouselEl.children[0].children[0].children; + 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")' @@ -2382,7 +2389,7 @@ describe('mapAppHtml results side panel: self-fetch (category_search)', () => { sendToolResult(searchResult); for (let i = 0; i < 20; i++) await Promise.resolve(); - const [, , thumb] = carouselEl.children[0].children[0].children; + const [, thumb] = carouselEl.children[0].children[0].children; expect(thumb.className).toBe('card-thumb empty'); }); }); @@ -2434,7 +2441,7 @@ 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 @@ -2469,25 +2476,21 @@ 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(' { @@ -2563,12 +2566,19 @@ describe('mapAppHtml results side panel: one panel per render', () => { ( 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 () => { + 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) => @@ -2626,7 +2636,16 @@ 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']); + // 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([ @@ -2680,7 +2699,7 @@ describe('mapAppHtml results side panel: one panel per render', () => { }>; } ).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 above the carousel, and moves the legend up above it', async () => { @@ -2742,8 +2761,9 @@ describe('mapAppHtml results side panel: one panel per render', () => { expect(legendEl.style.bottom).toBe(''); }); - it("colors a row's badge like its pin when the caller set a color", async () => { - const { sendToolResult, setFetchImpl, carouselEl } = 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: [] }) @@ -2765,12 +2785,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' } ] } @@ -2778,12 +2792,10 @@ describe('mapAppHtml results side panel: one panel per render', () => { }); for (let i = 0; i < 6; i++) await Promise.resolve(); - const badges = ( - carouselEl.children[0] 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]); }); }); @@ -2792,13 +2804,24 @@ describe('mapAppHtml results side panel: caller labels', () => { const badges = (panel: { children: unknown[] }) => ( panel.children[0] as { - children: Array<{ children: Array<{ textContent: string }> }>; + 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 } @@ -2851,7 +2874,7 @@ describe('mapAppHtml results side panel: caller labels', () => { expect(badges(carouselEl)).toEqual(['1', '2', '3']); }); - it('shows a letter label in the panel row, as on its pin', async () => { + it('shows a letter label on the card, as on its pin', async () => { const { sendToolResult, setFetchImpl, carouselEl, markerConstructorCalls } = loadScriptSandbox(); setFetchImpl(noDetails); @@ -2869,8 +2892,9 @@ 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(carouselEl)).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]); }); }); @@ -2914,8 +2938,8 @@ describe('mapAppHtml results side panel: photos without an MCP host', () => { }>; } ).children[0]; - expect(row.children[2].className).toBe('card-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")' ); }); @@ -2967,7 +2991,7 @@ describe('mapAppHtml results carousel: selecting a place', () => { expect(active(cards)).toEqual([true, false, false]); expect(active(pins)).toEqual([true, false, false]); - expect(pins[0].className).toBe('marker-badge active'); + 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; @@ -3061,6 +3085,22 @@ describe('mapAppHtml results carousel: selecting a place', () => { }); }); +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(); From edab5c67f9f0f2e52e84aca26c64409c25b62598 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 16:13:47 -0400 Subject: [PATCH 7/7] feat(render_map_tool): light and dark mode for the map's cards and chips The cards, summary chip, legend and messages follow the host's theme (MCP Apps hostContext.theme, at initialize and on host-context-changed), falling back to prefers-color-scheme. Colors use the host's style variables (--color-background-primary etc.) when sent, with light/dark fallbacks otherwise. The map and its pins are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/resources/ui-apps/mapAppHtml.ts | 100 +++++++++++++++--- .../ui-apps/mapAppHtml.script.test.ts | 90 +++++++++++++++- 3 files changed, 174 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52887b5..ccc1cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **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`). diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 34bbe75..26fb3e0 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -45,23 +45,48 @@ export function renderMapAppHtml(params: { @@ -246,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; @@ -260,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() { diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index d37a431..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( @@ -192,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) => { @@ -205,7 +222,8 @@ function loadScriptSandbox(options?: { }, document: { getElementById: getElementById, - createElement: () => fakeElement() + createElement: () => fakeElement(), + documentElement: rootEl }, mapboxgl: { accessToken: '', @@ -240,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 { @@ -257,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 { @@ -288,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, @@ -348,6 +388,52 @@ describe('mapAppHtml summary chip', () => { }); }); +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();