diff --git a/CHANGELOG.md b/CHANGELOG.md index 9334a82..b6b8d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ### Fixed +- **`style_builder_tool`: an object `filter` on your own data no longer produces an invalid style.** The schema accepts `filter` as an object, and on a basemap layer that shorthand is resolved against Streets v8 field metadata — which is what makes `{ class: "park" }` work. Custom data has no such metadata, so the `custom_sources` path assigned the object straight to `layer.filter`, and the style spec rejects that outright: `validate_style_tool` reports "array expected, object found", but only once `create_style_tool` had already been handed the JSON. It is now converted to an expression (`{ status: "active" }` → `["==", ["get", "status"], "active"]`, an array value → `match`, several keys → `all`) and the conversion reported, since the object form is what a caller carries over from a basemap layer in the same call and its intent needs no metadata to read. A `filter` that is a bare string or number is rejected instead: there is no property to test against, so nothing correct can be built from it. +- **`style_builder_tool`: a heatmap over your own data no longer discards its colour and opacity.** `heatmap-color` is the one colour property that is a ramp rather than a colour — the spec expects an expression over `["heatmap-density"]` — so it is absent from the colour table, and `heatmap-opacity` was missing from the opacity table as well. Both lookups returned nothing, which took `color`, `expression`, `property_based` and `opacity` with them: `render_type: "heatmap"` came out with no `paint` at all and was reported as built successfully. `color` now becomes a single-hue density ramp from transparent, `expression` passes through as the ramp itself, and `opacity` sets `heatmap-opacity`. `property_based` is reported as belonging to `heatmap-weight` rather than accepted and dropped — density is computed from the points, not read off a feature. This is the render type `design-data-driven-style` names for point data. +- **`style_builder_tool`: `width` on a layer it cannot apply to says so.** It sets `line-width`, and the schema says as much, but it was accepted on every render type and dropped on all but lines. Named rather than mapped onto something adjacent: inferring `circle-radius` from a field called width is a different property, so a circle layer is pointed at `paint.circle-radius` instead. +- **`style_builder_tool`: a Classic base with no layers is a redirect, not a style.** A Classic base authors nothing, so `layers: []` produced a lone background layer and reported "Style Built Successfully" — a bare colour presented as a finished map. It is also the shape a caller lands in having read `base_style: "dark-v11"` as "give me dark-v11", which is the likeliest thing they meant and the one thing this tool cannot do: it builds new self-contained styles, so using a Classic style as it actually looks is a reference (`mapbox://styles/mapbox/dark-v11`, [listed here](https://docs.mapbox.com/map-styles/guides/classic-styles/)) rather than a build. The rejection names all three readings — reference the real style, configure Standard instead (`lightPreset: "night"` for a dark base, `standard-satellite` for an imagery one), or list the features a self-contained stack should draw. Standard is exempt: a config-only style with no layers of its own is the normal shape there, because the import supplies the map. A Classic stack that is merely thin is built and the shortfall named, since a couple of layers over imagery is legitimate. +- **`style_builder_tool`: a layer over your own data with no `color` no longer comes out opaque black.** Every colour property defaults to `#000000`, so omitting `fill-color` is not "unstyled" — it is a black slab over the map, the same failure a `match` with no fallback arm has. The basemap path has always filled in a colour it wasn't given; this one now does too, and says which. +- **`style_builder_tool`: bare hex on your own data is normalised, as it is on a basemap layer.** `color: "7b61ff"` reached the style verbatim, which the spec cannot parse, so it failed validation at upload rather than at the point the mistake was made. Both paths now share one helper — which also stops a named colour being turned into `#red`, as the basemap path did. +- **`style_builder_tool`: a `fill-extrusion` over your own data now has a height.** `fill-extrusion-height` defaults to `0`, so the layer was present, valid and flat — the same "looks finished, draws nothing" shape as a symbol layer with no `text-field`. It is extruded by the `height` property, with the assumption reported because the tool cannot read the source to check it. +- **`style_builder_tool`: a `fill-extrusion` over your own data is left unslotted.** It was being given the overlay slot, contradicting the reasoning applied to a basemap extrusion 40 lines away: real 3D geometry depth-sorts against the buildings around it, and any slot flattens it into the 2D stack. Whose data it is doesn't change that. +- **`style_builder_tool`: `zoom_based` says so when it had nothing to ramp.** On a layer over your own data it ramps `opacity` and, on a line, `width` — set with neither, it was accepted and did nothing, which is the silence the rest of this tool is careful about. A colour ramp goes through `expression`, and the correction says so. +- **`style_builder_tool`: a layer over your own data is no longer styled with the literal colour only.** `expression` and `property_based`/`property_values` were accepted by the schema and dropped on the `custom_sources` path, so a choropleth came out as a fill with no `fill-color` at all — which the style spec draws as opaque black over the whole map. They now go through the same expression builder as a basemap layer, and `zoom_based` ramps `opacity` and `width`. A category `match` always gets a fallback arm, since without one an unlisted value draws nothing; `color` sets it, and the colour used is reported when it wasn't given. This was the one path the `design-data-driven-style` prompt routes choropleths through. +- **`style_builder_tool`: a custom `expression` no longer lands in numeric paint properties.** `generateExpression` returned it whatever property it was asked for, so a layer with both `expression` and `opacity` got the same colour ramp in `fill-opacity`, where the spec expects a number. The expression describes the colour, and is now applied only there. The zoom-ramp maths moved to its own helper so the custom-source path can ask for a ramp without inheriting the colour escape hatches. +- **`style_builder_tool`: layers no longer collide on id.** Ids derive from what a layer draws, so two layers over the same feature produced the same one — a filtered pair off a single GeoJSON source (`zones-fill` twice), or the same basemap feature asked for twice. Duplicate ids are invalid per the spec and the collision was silent; they are now suffixed, since asking for a feature twice is legitimate. +- **`style_builder_tool`: `render_type: "symbol"` over your own data now draws something.** The layer got no `layout` at all, and a symbol layer with neither `text-field` nor `icon-image` renders nothing — present, valid and invisible. It is now labelled from the `name` property, with the assumption reported because the tool cannot read the source to check it. Still no `icon-image`: a literal resolves against the Streets sprite and would put the same generic pin on every feature. +- **`style_builder_tool`: a `fill-extrusion` layer no longer reports `inferred slot "undefined"`.** It is left unslotted deliberately — real 3D geometry depth-sorts against the buildings around it — but the correction interpolated the absent slot and then warned about drawing over the street labels, which is the opposite of what the code chose. +- **`style_builder_tool`: `standard_config` now covers the whole documented Mapbox Standard config surface.** It exposed 26 of the 44 properties, and because the schema strips unknown keys, the other 18 could be set and dropped with no error — the same silence the target check exists to end. Added: the indoor group (`showIndoor`, `showIndoorLabels`, `colorIndoorLabelHighlight`, `colorIndoorLabelSelect`), the POI land-use colours (`colorCommercial`, `colorEducation`, `colorMedical`, `colorIndustrial`), the granular 3D toggles (`show3dBuildings`, `show3dTrees`, `show3dLandmarks`, `show3dFacades`), `colorLand`, `colorBuildings` and `fuelingStationModePointOfInterestLabels`. A test pins the list against the reference documentation so the next addition upstream shows up as a failure rather than as silence. +- **`style_builder_tool`: the build summary reports every config property it set.** It walked a written-out list covering 8 of the 15 `show*` toggles and 6 of the 22 `color*` overrides, so most of the config surface was applied to the import and then left out of the report — and any property added to the schema was omitted by default. The summary is now derived from what was actually set. +- **`style_builder_tool`: `colorLand` is what the `background_color` rejection points at.** Setting `global_settings.background_color` on Standard was rejected with a pointer to `colorWater`/`colorGreenspace` — neither of which is the land colour. `colorLand` is Standard's equivalent of a Classic background. Naming the wrong replacement undercuts the point of rejecting the option at all. +- **`style_builder_tool`: the summary describes a custom-source layer by the name you gave it.** `layer_type` is free text once `source_id` is set, so it was still looked up in Streets v8 — reporting a GeoJSON layer named `water` as "water layer (Polygon geometry)". +- **`style_builder_tool`: `action: "hide"` no longer silently does nothing on Standard.** Hiding worked by omitting the layer, which hides a feature on Classic (where the tool authors the whole stack) but not on Standard, where the basemap draws it through the import — and the summary reported it hidden either way. On Standard the tool now sets the matching config toggle (`poi_label` → `showPointOfInterestLabels`, `place_label` → `showPlaceLabels`, `transit_stop_label` → `showTransitLabels`, `building` → `show3dBuildings`, `admin` → `showAdminBoundaries`) and reports which. `building` maps to `show3dBuildings` rather than `show3dObjects`, which would take the 3D trees and landmarks with it; `show3dBuildings` is now exposed on `standard_config` alongside it. Standard exposes no toggle for water, landuse or the road network, so `hide` on those is now rejected with a pointer to `theme` and the `color*` overrides rather than quietly producing an unchanged style. The answer is decided from the resolved source layer, so a name the tool worked out from `filter_properties` gets the same treatment as every other action; an unrecognised layer type still reports as unrecognised; a layer of your own from `custom_sources` is still hidden by omission on either target, since it belongs to you rather than to the import; and a `hide` that contradicts an explicit `show*` value says so instead of resolving it in silence. Hiding a feature and also drawing it is reported rather than rejected — turning the basemap's POIs off and drawing a filtered subset over the top is a real technique — but an _unfiltered_ redraw is called out as reinstating what the toggle just hid. Each of those lines is reported once per feature, not once per layer. +- **`style_builder_tool`: a dark or satellite Classic `base_style` no longer produces a light vector map.** Only `base_style === 'standard'` was ever branched on, so all eight Classic values produced byte-identical output — `dark-v11` and `navigation-night-v1` were light `#f8f4f0` maps and `satellite-v9` had no imagery. The base name now decides light vs dark (using the two land colours the tool already had) and, for `satellite-v9` and `satellite-streets-v12`, adds a `mapbox.satellite` raster layer in place of the background. A Classic base remains **self-contained — not a style import** — so it cannot reproduce the named style, and bases within a group are deliberately equivalent rather than given invented differences. An explicit `global_settings` value overrides all of it. +- **`style_builder_tool`: `slot` on a Classic style is rejected instead of dropped.** `slot` lives on the layer rather than at the top level, so it was invisible to the wrong-target check and was the one cross-target option still being ignored in silence — despite the documented promise that they are rejected. Carrying a Standard example over to a Classic base now fails with a pointer to layer ordering. +- **`style_builder_tool`: layers built from `custom_sources` now report their inferred slot**, as basemap layers already did, and a fill is told about `slot: "bottom"` — the overlay default is right for a zone overlay and wrong for a choropleth, which the docs and the data-driven prompt had always specified as `bottom`. +- **`style_builder_tool`: `standard_config.showRoadsAndTransit` is rejected instead of ignored.** It is a Mapbox Standard **Satellite** property; the plain Standard style this tool imports has no such config, and `base_style` offers no Satellite value, so setting it landed an inert key in the import config. The target check only guarded Standard-versus-Classic, never a property belonging to a different Standard style. It stays in the schema deliberately — the schema strips unknown keys, so removing it would trade one silence for another. +- **`style_builder_tool`: a `custom_sources` entry keyed `composite` (or `satellite` on a satellite base) is rejected.** Custom sources are merged last so that neither target branch can clobber them, which meant a caller's source keyed like the builder's own replaced the basemap source instead of sitting beside it — and every basemap layer then read the caller's data. The collision is now named up front, with a suggested rename, instead of producing a style whose basemap silently points at the wrong source. - **`validate_expression_tool` / `validate_style_tool`**: Both tools now delegate expression and style validation to the official `@mapbox/mapbox-gl-style-spec` package (the same logic mapbox-gl-js runs at runtime) instead of a hand-rolled reimplementation. Fixes two disagreements with real mapbox-gl-js behavior around the `["zoom"]` expression placement rule (#123): - `validate_expression_tool` no longer false-positives on ordinary zoom-based `interpolate`/`step` expressions (it previously misidentified the interpolation-type argument, e.g. `["linear"]`, as an unknown operator). - `validate_style_tool` now correctly flags `["zoom"]` expressions nested inside e.g. a `case` (invalid per spec — zoom may only be used as the top-level input to `step`/`interpolate`), which it previously missed entirely. @@ -10,9 +34,23 @@ - **Style ID validation for `style_comparison_tool`**: `before` and `after` inputs are now validated to contain only alphanumeric characters, hyphens, and underscores (after stripping the optional `mapbox://styles/` prefix). Validation is enforced at both the Zod schema layer and inside `processStyleId()`. Malformed style IDs are rejected with a descriptive error before any URL is constructed. +### Changed + +- **`style_builder_tool` now names the config property when you recolour the basemap on Standard.** Adding a Streets v8 layer there draws a second copy over the import's own rather than restyling it. The layer is still generated — an overdraw is right when the recolour is filtered to a subset the config cannot express — but the tool points at `colorWater`, `colorGreenspace`, `colorRoads`, `colorAdminBoundaries`, `colorPlaceLabels` or `colorPointOfInterestLabels` instead. +- **Prompts now branch on the target instead of assuming Standard.** `create-and-preview-style` emitted the Standard import block, `lightPreset` and "every layer needs a `slot`" even when passed a Classic `base_style`, asking for options that base rejects or ignores; it now gives Classic its own instructions. `build-custom-map` and `setup-mapbox-project` translated theme words ("dark", "monochrome", "satellite") as if they were Classic style names, and now map them onto `standard_config`. All three also stop describing `style_builder_tool` as taking a free-text description — it takes structured parameters. +- **`debug-mapbox-integration` covers the Standard-specific rendering failures.** A new phase, gated on the style having an `imports` array, walks the failures that throw no error and log nothing: a layer invisible for want of emissive strength, a layer over the street labels for want of a `slot`, an occluded route, a "hidden" basemap layer that is still drawn, half-applied dark mode, and `setPaintProperty` on a layer that belongs to the import. +- **`design-data-driven-style` routes data through `custom_sources`** rather than telling the model to hand-author a `sources` block, which is where slots and emissive strength got lost. +- **Eval covers the Classic path.** Every case steered at Standard, so nothing checked that an explicit Classic request produces a coherent Classic style; a new case does, alongside one for hiding a basemap feature on Standard. The eval now reads the tool surface from `ALL_TOOLS`; it destructured a `TOOLS_WITH_UI` export that does not exist, and masked the `undefined` with `|| []`, so a tool moved out of `CORE_TOOLS` would have silently vanished from the measured surface. +- **`setup-mapbox-project`'s satellite recipe is buildable.** It suggested `base_style: "standard"` with an imagery layer, which `style_builder_tool` cannot produce — `custom_sources` takes GeoJSON and vector tilesets, not raster. It now points at the Classic satellite base, or at referencing `standard-satellite` in the app. +- **Eval leaves thinking at the model default and survives a failing case.** It hardcoded `thinking: { type: "disabled" }`, which is rejected outright on Claude Fable 5 — so the `EVAL_MODEL` override broke on a model someone would reach for — and which measurably reduces how readily the model reaches for tools, the only thing this eval scores. Effort is now settable through `EVAL_EFFORT` for the cost trade instead. Cases run under `Promise.allSettled`, so one rate limit no longer rejects the whole run and discards every other case's results; a case that fails to run is reported and still counted against the total. Exhausting `MAX_TURNS` mid-conversation is marked `TRUNCATED` rather than scored quietly as a complete transcript, and a tool whose schema fails to convert warns instead of being silently exposed with no parameters. +- **`npm run lint` covers `scripts/*.mjs`.** The eval script was matched by neither the lint script's globs nor `lint-staged`, so it was the one source file in the repo going through no linter or formatter. + ### Documentation - **Engineering standards**: Note that unsolicited third-party directory/discovery listing PRs are out of scope and will be closed without review. +- **Style Builder docs**: Document what a Classic `base_style` actually contributes (it does not import the named style's layers), how `action: "hide"` differs per target, why recolouring the basemap on Standard overdraws it, and the choropleth slot exception. Removes the stale "custom data sources need to be added separately" limitation, superseded by `custom_sources`. +- **`resource://mapbox-style-layers`**: The worked example recoloured water and landuse with custom layers over Standard — the anti-pattern the same resource warns against two sections earlier. It now configures the basemap and keeps a layer only for the filtered road highlight, and gains a `custom_sources` example. +- **CLAUDE.md**: Correct the `skills/` description — the skills moved to `mapbox/mapbox-agent-skills` and the directory is a pointer. ## 0.8.1 - 2026-06-11 diff --git a/CLAUDE.md b/CLAUDE.md index b7d7228..5c76518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ The codebase organizes into: - `src/prompts/` - MCP prompt implementations with `BasePrompt` abstract class and registry - `src/resources/` - Static reference data (style specs, token scopes, Streets v8 fields) - `src/utils/` - HTTP pipeline, JWT parsing, tracing, and version utilities -- `skills/` - Agent Skills providing domain expertise (cartography, security, style patterns) +- `skills/` - Pointer to the Agent Skills, which now live in the `mapbox/mapbox-agent-skills` repository ## Key Architectural Patterns @@ -39,7 +39,7 @@ The codebase organizes into: **Prompt Registry:** Prompts are registered in `src/prompts/promptRegistry.ts`. To add a new prompt, create the prompt class and add it to the `ALL_PROMPTS` array. The main server automatically registers all prompts with proper Zod schema conversion. -**Agent Skills:** Domain expertise provided through `skills/` directory. Each skill is a folder with `SKILL.md` containing YAML frontmatter and markdown instructions. Skills teach AI assistants about map design (cartography), security (token management), and implementation (style patterns). Skills are discovered by Claude Code, uploadable to Claude API, or usable in Claude.ai. See `skills/README.md` for details. +**Agent Skills:** Domain expertise for map design (cartography), security (token management) and implementation (style patterns) is no longer in this repository — the skills moved to [mapbox-agent-skills](https://github.com/mapbox/mapbox-agent-skills), and `skills/README.md` is a pointer with install instructions. This server's own guidance lives in the tool descriptions, the prompts under `src/prompts/`, and the resources under `src/resources/`; keep those in sync with the skills when Standard/Classic guidance changes. ## Essential Workflows diff --git a/README.md b/README.md index 9759c7a..977ba1e 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Reference data is exposed as MCP Resources (see [Resources](#resources) section) Complete set of tools for managing Mapbox styles via the Styles API: -**Style Builder Tool** - Create and modify Mapbox styles programmatically through conversational prompts +**Style Builder Tool** - Build Mapbox style JSON from structured parameters: a base style, a layer list, the config surface for that base, and your own GeoJSON or tilesets. Defaults to Mapbox Standard, where it configures the basemap through the style import and gives every custom layer a slot and the emissive strength it needs to survive the night preset. Classic bases work differently in every respect — the tool authors the whole layer stack there. 📖 **[See the Style Builder documentation for detailed usage and examples →](./docs/STYLE_BUILDER.md)** @@ -829,7 +829,7 @@ Create a new Mapbox map style and generate a shareable preview link with automat - `style_name` (required): Name for the new map style - `style_description` (optional): Description of the style theme or purpose -- `base_style` (optional): Base style to start from (e.g., "streets-v12", "dark-v11") +- `base_style` (optional): Base style to start from. Defaults to `"standard"` (Mapbox Standard), which is the right choice for almost every new style. Pass a Classic style (`"streets-v12"`, `"dark-v11"`, …) only when a classic style is explicitly wanted. - `preview_location` (optional): Location to center the preview map - `preview_zoom` (optional): Zoom level for the preview (0-22, default: 12) @@ -839,6 +839,7 @@ Create a new Mapbox map style and generate a shareable preview link with automat 2. Creates a new public token if needed 3. Creates the map style 4. Generates a preview link +5. Runs the `prepare-style-for-production` validation workflow **Example usage:** @@ -847,11 +848,16 @@ Use prompt: create-and-preview-style Arguments: style_name: "My Custom Map" style_description: "A dark-themed map for nighttime navigation" - base_style: "dark-v11" + base_style: "standard" preview_location: "San Francisco" preview_zoom: "13" ``` +Note that a dark map keeps `base_style: "standard"` — the workflow sets the Standard style's +`lightPreset` to `night` rather than switching to `dark-v11`. The light preset relights the whole +basemap coherently, and it can be changed at runtime without reloading the style. Custom layers do +not follow the preset, which is why the workflow also gives them emissive strength `1`. + ### build-custom-map Use conversational AI to build a custom styled map based on a theme description. @@ -865,9 +871,12 @@ Use conversational AI to build a custom styled map based on a theme description. **What it does:** -1. Uses the Style Builder tool to create a themed style based on your description -2. Creates the style in your Mapbox account -3. Generates a preview link +1. Translates the theme into Mapbox Standard configuration — `theme`, `lightPreset` (this is how a + theme goes dark), `color*` overrides and `show*` toggles — and adds custom layers only for what + the config cannot reach +2. Uses the Style Builder tool to build the style JSON +3. Creates the style in your Mapbox account +4. Generates a preview link **Example usage:** diff --git a/cspell.config.json b/cspell.config.json index e31b77f..1fb6830 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -2,12 +2,31 @@ "version": "0.2", "language": "en", "words": [ + "aeroway", + "basemap", "bbox", + "choropleth", + "colour", + "colours", "denoise", + "emissive", + "fontstack", + "geofence", + "geospatial", + "greenspace", + "hillshade", "isochrone", + "landuse", "mapbox", + "maplibre", + "maxzoom", + "minzoom", "mmss", + "recolour", + "recolouring", + "recolours", "tilequery", + "tileset", "GHSA", "mxjg" ], diff --git a/docs/STYLE_BUILDER.md b/docs/STYLE_BUILDER.md index 6c08f13..544686e 100644 --- a/docs/STYLE_BUILDER.md +++ b/docs/STYLE_BUILDER.md @@ -2,7 +2,9 @@ ## Overview -The Style Builder tool is a powerful utility for creating and modifying Mapbox styles programmatically. It provides a conversational interface to build complex map styles with various customizations for layers, labels, boundaries, roads, POIs, and more. +The Style Builder tool is a utility for creating and modifying Mapbox styles programmatically. You describe what you want in conversation and the assistant calls the tool with structured parameters — a `base_style`, a `layers` array, the config surface for that base, and `custom_sources` for your own data. It covers layers, labels, boundaries, roads, POIs, and more. + +**Mapbox Standard is the default and the right choice for almost every style.** It is configured rather than authored: you set `theme`, `lightPreset`, `show*` toggles and `color*` overrides on the import, and add layers only for data the basemap doesn't carry. Classic bases are for when a classic style is explicitly wanted, and they work differently in every respect — see [Standard and Classic Take Different Options](#standard-and-classic-take-different-options). ## Important Limitations @@ -139,17 +141,207 @@ The builder can optimize styles for: Create multiple versions of a style: -- Light and dark modes +- Light and dark modes — on Mapbox Standard these are the same style with a different + `lightPreset` (`day`, `dawn`, `dusk`, `night`), not two separate styles. The preset can be + switched at runtime without reloading the style - Seasonal variations - Brand-specific color schemes +## Working with Mapbox Standard + +Standard is the default base style, and it works differently from Classic styles in ways that +matter for anything you add on top. + +**Configure before you add layers.** Standard exposes its basemap through style-import config: +`theme` (`default`/`faded`/`monochrome`), `lightPreset`, `show*` toggles for labels, POIs, roads +and 3D objects, and `color*` overrides for water, roads, greenspace, labels and boundaries. +Setting one of those is cheaper than adding a layer, survives basemap updates, and can't land in +the wrong place in the stack. Reach for a custom layer only for data the basemap doesn't carry. + +**Every custom layer needs a slot.** Mapbox owns the basemap layer order on Standard, so you don't +hand-order layers into it — you place each one in a slot: + +| Slot | Position | Put here | +| -------- | ------------------------------------------------------- | -------------------------------------------------------------- | +| `bottom` | Above land / landuse / water, **below** roads | Choropleths, rasters, terrain | +| `middle` | Above roads and lines, **behind** 3D buildings & labels | Data overlays, zone fills, heatmaps, routes, custom POI layers | +| `top` | Above POI labels, **behind** place & transit labels | Markers, active selections | + +Omitting `slot` is not a neutral default — the layer draws above every basemap layer, including +street labels. The builder infers a slot from the layer type when you leave it off, and tells you +which one it picked. + +**Custom fill, line and circle layers need emissive strength.** Standard is a lit scene, and +`fill-emissive-strength`, `line-emissive-strength` and `circle-emissive-strength` all default to +`0` — meaning the scene lights the layer, so it falls into shadow and goes nearly invisible under +the `dusk` and `night` presets. The builder sets these to `1` for you on Standard styles. Symbol +layers need nothing (icon and text emissive strength already default to `1`), and `fill-extrusion` +is left alone because it is real 3D geometry that should be lit by the scene. + +Routes should also set `line-occlusion-opacity`; its default of `0` hides the part of the line +that passes behind 3D buildings. + +## Adding Your Own Data + +The `layer_type` lookup covers Mapbox Streets v8 basemap features — roads, landuse, water, +POIs. It does not cover your data. For that, declare a source in `custom_sources` and point a +layer at it with `source_id`: + +```json +{ + "style_name": "Delivery", + "base_style": "standard", + "custom_sources": { + "zones": { "type": "geojson", "data": "https://example.com/zones.geojson" }, + "route": { "type": "geojson", "data": "https://example.com/route.geojson" } + }, + "layers": [ + { + "layer_type": "Delivery zones", + "source_id": "zones", + "render_type": "fill", + "action": "color", + "color": "#7b61ff", + "opacity": 0.6 + }, + { + "layer_type": "Route", + "source_id": "route", + "render_type": "line", + "action": "color", + "color": "#3b6df5", + "width": 4 + } + ] +} +``` + +Notes: + +- **`render_type` is required** for these layers. A GeoJSON URL or a tileset gives the builder + nothing to inspect, so it cannot pick between fill, line and circle for you. +- **Colour by value the same way as any other layer.** `expression` takes a ramp + (`["interpolate", ["linear"], ["get", "anomaly"], …]`) and `property_based` + + `property_values` builds a category `match`, where `color` becomes the fallback arm — a `match` + without one draws nothing at all for a value you didn't list, so the builder always emits one + and tells you which colour it used. `zoom_based` ramps `opacity` and `width` with zoom. +- **A `symbol` layer is labelled from the `name` property.** A symbol layer with neither + `text-field` nor `icon-image` renders nothing, and the builder cannot read your source to find + the right field, so it assumes `name` and says so. Edit `layout.text-field` in the generated + JSON if your features name it differently, or use `render_type: "circle"` for plain points. +- For `type: "vector"`, also set `source_layer` — the layer name inside the tileset. GeoJSON + has no source layers. +- **`composite` is reserved**, as is `satellite` on a satellite base: those are the ids the builder + gives the basemap's own sources. Custom sources are merged last, so an entry keyed the same way + would replace the basemap rather than sit beside it, and every basemap layer would read your data. + The build stops and names the collision instead. +- **Placement differs from basemap layers on purpose.** A basemap fill (parks, water) goes in + `bottom`, under the road network. A fill of your own data is an overlay, so it goes in + `middle` — above roads, behind labels and 3D buildings. Symbols go in `top`. +- **A choropleth is the exception: set `slot: "bottom"` explicitly.** The overlay default is right + for a zone or geofence but wrong for a fill whose colour encodes a value, which wants the road + network reading over it. The builder reports the slot it inferred either way, so the line to check + is in the auto-corrections. +- Lines from your own data get `line-occlusion-opacity`, since the canonical user line is a + route and a route vanishing behind buildings is a bug. Basemap roads are left alone, where + being hidden by a building is correct. + +## Standard and Classic Take Different Options + +The two targets are configured differently, and passing options for the wrong one is +**rejected rather than ignored** — so a setting that would have done nothing tells you +immediately instead of shipping a style that looks unchanged. That applies to `slot` too, which +lives on the layer rather than at the top level: pass it with a Classic `base_style` and the build +stops. + +| | Standard | Classic | +| ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | +| Appearance | `standard_config` (`theme`, `lightPreset`, `show*`, `color*`) | `global_settings` (`background_color`, `label_color`, `mode`) | +| Slots | yes | no — order the `layers` array | +| Emissive strength | yes (lit scene) | no lighting to shadow layers | +| Style import | imports `mapbox://styles/mapbox/standard` | none — the style is self-contained | +| Background layer | supplied by the import | authored into the style | +| Dark mode | `lightPreset: "night"` | `mode: "dark"`, or a dark-named `base_style` | +| Land colour | `standard_config.colorLand` | `global_settings.background_color` | +| Basemap features | drawn by the import, restyled through config | only what you list in `layers` is drawn | +| `action: "hide"` | sets the matching `show*` config toggle | omits the layer from the stack | + +On Classic, `label_color` sets `text-color` on label layers, and a colour set on an individual +layer takes precedence over it. + +### What a Classic `base_style` Actually Gives You + +A Classic base is **not a style import**, by design: the style stays self-contained, with no +`imports` array and no dependency on another style. The builder authors the stack, so **only the +layers you list get drawn**. An empty `layers` array is therefore **rejected** rather than built — +`base_style: "dark-v11"` with no layers would be a bare background reported as a finished style, and +what it usually means is "give me dark-v11", which is a reference rather than a build. The rejection +names the options, including the one below. + +The consequence is that the builder cannot reproduce the named style. It has no access to that +style's palette, and inventing one would mean attributing made-up cartography to a Mapbox style. So +the base name decides exactly two things: + +| From the base name | Bases | Effect | +| ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Dark | `dark-v11`, `navigation-night-v1`, `satellite-v9`, `satellite-streets-v12` | `#1a1a1a` land, white `text-color`, black label halo | +| Light | `streets-v12`, `light-v11`, `outdoors-v12`, `navigation-day-v1` | `#f8f4f0` land, default label colour | +| Imagery | `satellite-v9`, `satellite-streets-v12` | `mapbox.satellite` raster layer in place of the background | + +**Bases within a group are equivalent.** `dark-v11` and `navigation-night-v1` produce the same +output, because nothing available to the builder distinguishes them. That is intentional: the +alternative is a difference invented to look like fidelity it doesn't have. + +An explicit `global_settings` value overrides all of it, and the land colour follows the mode you +set rather than the one the base named. + +If you want the real `dark-v11` — its actual palette, road hierarchy and label treatment — reference +`mapbox://styles/mapbox/dark-v11` directly in your map, or use `base_style: "standard"` with +`lightPreset: "night"`. This tool builds a new style; it is not a way to fetch an existing one. + +### Hiding Things + +`action: "hide"` means different things per target, because on Standard the feature is not yours to +remove: + +- **Classic** — the layer is left out of the stack, which is what hides the feature. +- **Standard** — the basemap draws it through the import, so omitting a layer hides nothing. The + builder sets the matching config toggle instead: `poi_label` → + `showPointOfInterestLabels`, `place_label` → `showPlaceLabels`, `transit_stop_label` → + `showTransitLabels`, `building` → `show3dBuildings`, `admin` → `showAdminBoundaries`. + (`show3dBuildings` rather than `show3dObjects`, which would take the 3D trees and landmarks + with it. Set `show3dObjects: false` yourself when you do want all 3D gone.) +- Standard exposes no toggle for water, landuse or the road network itself, so `hide` on those is + **rejected** rather than silently doing nothing. Make them recede with `theme: "faded"` or + `"monochrome"` and the `color*` overrides. (`showRoadLabels` hides road labels and shields; + `showPedestrianRoads` hides paths — neither removes the carriageways.) +- **A layer of your own** (`source_id` set) is hidden by omission on either target. It is yours to + leave out rather than the import's to remove, so no config toggle is involved. + +The toggle is chosen from the source layer the builder resolved, not from the string you passed, so +`layer_type: "pois"` with `filter_properties: { maki: "restaurant" }` hides POI labels just as +`layer_type: "poi_label"` does. A layer type it cannot resolve at all is reported as unresolved — +the same suggestion list any other action gets — rather than as something Standard cannot hide. + +### Recolouring the Basemap on Standard + +Adding a Streets v8 layer over Standard does not restyle the basemap's own layer — it draws a +second copy on top, which you then keep in sync by hand and which picks up defaults (a fill +outline, an opacity) the basemap never had. The builder still generates it, because an overdraw is +the right answer when the recolour is filtered to a subset the config cannot express, but it tells +you which config property retints the basemap itself: `colorWater`, `colorGreenspace`, +`colorRoads`, `colorAdminBoundaries`, `colorPlaceLabels`, `colorPointOfInterestLabels`. + ## Best Practices -1. **Start Simple**: Begin with basic requirements and iteratively add complexity -2. **Test at Multiple Zooms**: Ensure your style works well across zoom levels -3. **Consider Performance**: More layers and complex filters can impact rendering speed -4. **Use Consistent Naming**: When creating custom layers, use clear, descriptive IDs -5. **Document Your Choices**: Keep notes on why certain styling decisions were made +1. **Configure Before Layering**: On Standard, try `standard_config` before adding a custom layer +2. **Start Simple**: Begin with basic requirements and iteratively add complexity +3. **Test at Multiple Zooms**: Ensure your style works well across zoom levels +4. **Test at Night**: Preview with `lightPreset: "night"`, not just the default `day` — a missing + emissive strength is invisible in day-time review and obvious at night +5. **Consider Performance**: More layers and complex filters can impact rendering speed +6. **Use Consistent Naming**: When creating custom layers, use clear, descriptive IDs +7. **Document Your Choices**: Keep notes on why certain styling decisions were made ## Troubleshooting @@ -157,8 +349,21 @@ Create multiple versions of a style: 1. **Resources Not Loading**: Remember that sprite and glyph resources may not be accessible in Claude Desktop 2. **Layer Conflicts**: Check layer ordering if elements appear hidden -3. **Performance Issues**: Reduce layer count or simplify filters for better performance -4. **Zoom Range Problems**: Verify minzoom and maxzoom settings on layers +3. **Custom layer covers the street labels**: The layer has no `slot`, or is in `top` when it + should be in `bottom` or `middle` +4. **Custom layer looks right by day and disappears at night**: The layer is missing its + `fill-`/`line-`/`circle-emissive-strength`, so the night preset lights it into shadow +5. **Route vanishes behind buildings**: Set `line-occlusion-opacity`, which defaults to `0` +6. **Dark mode looks half-applied**: `global_settings.mode: "dark"` only recolors custom layers. + On Standard, set `standard_config.lightPreset: "night"` instead +7. **A feature you hid is still on the map**: You are on Standard, where the import draws it. Use + the matching `show*` config toggle — see [Hiding Things](#hiding-things) +8. **A Classic style came out nearly empty**: A Classic base authors nothing for you. List every + feature you want in `layers`, or use `base_style: "standard"` +9. **Recolouring the basemap on Standard left the old colour visible underneath**: The custom layer + is a second copy, not a replacement. Use the `standard_config` `color*` override +10. **Performance Issues**: Reduce layer count or simplify filters for better performance +11. **Zoom Range Problems**: Verify minzoom and maxzoom settings on layers ### Getting Help @@ -180,9 +385,10 @@ The Style Builder tool: ## Limitations and Considerations - Some advanced Studio-only features may not be available -- Custom data sources need to be added separately - Sprite and font resources must be hosted and accessible - Complex expressions may need manual refinement +- A Classic base does not reproduce the named style's layers — you author every layer you want +- On Standard the builder cannot reach into the imported basemap: it configures it, or draws over it ## Integration with Other Tools diff --git a/package-lock.json b/package-lock.json index 128563c..a41a397 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "mapbox-mcp-devkit": "dist/esm/index.js" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.115.0", "@eslint/js": "^9.27.0", "@types/geojson": "^7946.0.16", "@types/node": "^22.0.0", @@ -55,6 +56,28 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -267,6 +290,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -3593,6 +3626,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6205,6 +6245,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -7535,6 +7582,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, @@ -10076,6 +10137,17 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10373,6 +10445,13 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index bbab8d9..7ec208a 100644 --- a/package.json +++ b/package.json @@ -18,19 +18,20 @@ "generate-version": "node scripts/build-helpers.cjs generate-version", "inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js", "inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts", - "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", + "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./scripts/**/*.mjs\"", + "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./scripts/**/*.mjs\" --fix", "prepare": "husky && node .husky/setup-hooks.js", "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.ts\"", "sync-manifest": "node scripts/sync-manifest-version.cjs", "test": "vitest", "tracing:jaeger:start": "docker run --rm -d --name jaeger -p 16686:16686 -p 14250:14250 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest", "tracing:jaeger:stop": "docker stop jaeger", - "tracing:verify": "node -e \"console.log('🔍 Verifying tracing setup with Jaeger...\\n1. Copy .env.example to .env and add your MAPBOX_ACCESS_TOKEN\\n2. Start Jaeger: npm run tracing:jaeger:start\\n3. Run server: npm run inspect:build\\n4. Check traces at: http://localhost:16686')\"" + "tracing:verify": "node -e \"console.log('🔍 Verifying tracing setup with Jaeger...\\n1. Copy .env.example to .env and add your MAPBOX_ACCESS_TOKEN\\n2. Start Jaeger: npm run tracing:jaeger:start\\n3. Run server: npm run inspect:build\\n4. Check traces at: http://localhost:16686')\"", + "eval:tools": "node scripts/eval-tools.mjs" }, "lint-staged": { - "*.{js,jsx,ts,tsx}": "eslint --fix", - "*.{js,jsx,ts,tsx,md,html,css}": "prettier --write" + "*.{js,jsx,mjs,ts,tsx}": "eslint --fix", + "*.{js,jsx,mjs,ts,tsx,md,html,css}": "prettier --write" }, "license": "MIT", "homepage": "https://github.com/mapbox/mcp-devkit-server#readme", @@ -64,6 +65,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.115.0", "@eslint/js": "^9.27.0", "@types/geojson": "^7946.0.16", "@types/node": "^22.0.0", diff --git a/scripts/eval-tools.mjs b/scripts/eval-tools.mjs new file mode 100644 index 0000000..8a93f4d --- /dev/null +++ b/scripts/eval-tools.mjs @@ -0,0 +1,485 @@ +#!/usr/bin/env node +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Tool-surface eval. + * + * Unit tests verify that a tool emits what we intended. They cannot tell us whether an + * agent reading our tool descriptions and schemas actually reaches for the right tool with + * the right arguments — and that is what most of the design guidance in this repo is: text + * a model reads. This eval closes that gap. + * + * It hands the model our real tool definitions (descriptions and JSON-Schema-converted zod + * input schemas, straight off the tool registry), gives it a task, and inspects the tool + * calls it makes. Where a call lands on style_builder_tool the tool is executed for real — + * it needs no network — so checks run against the style JSON that would actually ship. + * + * Checks are deterministic predicates rather than an LLM judge, because the thing being + * measured is structured output. That keeps the score stable run to run, so a change in it + * means a change in behavior rather than judge variance. + * + * Usage: + * npm run build && npm run eval:tools + * EVAL_MODEL=claude-opus-5 npm run eval:tools + * EVAL_EFFORT=low npm run eval:tools + * npm run eval:tools -- --json out.json + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { z } from 'zod'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +if (existsSync('.env')) { + for (const line of readFileSync('.env', 'utf-8').split('\n')) { + const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (m && !process.env[m[1]]) { + process.env[m[1]] = (m[2] || '').replace(/^["']|["']$/g, ''); + } + } +} + +const MODEL = process.env.EVAL_MODEL || 'claude-sonnet-5'; +const EFFORT = process.env.EVAL_EFFORT || ''; +// Enough for build → create → preview plus a closing turn. Exhausting it is reported rather than +// scored quietly: a truncated conversation fails checks for a reason that is not a behavior change. +const MAX_TURNS = 5; + +// Resolved against cwd rather than this file, so the script works from an npm script. +const REGISTRY = resolve(process.cwd(), 'dist/esm/tools/toolRegistry.js'); +if (!existsSync(REGISTRY)) { + console.error('Build output missing. Run `npm run build` first.'); + process.exit(1); +} + +// ALL_TOOLS rather than CORE_TOOLS so a tool moved into a capability-gated group stays visible +// here — the eval measures the surface an agent sees, and silently dropping one would read as a +// behavior change in the score. +const { ALL_TOOLS } = await import(pathToFileURL(REGISTRY).href); +const allTools = [...ALL_TOOLS]; + +// The style/design surface. Narrowed so the model isn't picking between 25 tools for a +// task about map appearance, and so the eval stays cheap. +const SURFACE = [ + 'style_builder_tool', + 'create_style_tool', + 'update_style_tool', + 'preview_style_tool', + 'list_styles_tool', + 'retrieve_style_tool', + 'validate_style_tool', + 'check_color_contrast_tool' +]; + +function toolDefinitions() { + const defs = []; + for (const tool of allTools) { + if (!SURFACE.includes(tool.name)) continue; + let input_schema; + try { + input_schema = z.toJSONSchema(tool.inputSchema, { + io: 'input', + unrepresentable: 'any' + }); + delete input_schema.$schema; + } catch (error) { + // Loudly: the fallback hands the model a tool with no parameters, so the eval would go on + // measuring a surface that is not the one we ship, and the score would drop with no clue why. + console.warn( + `WARNING: could not convert ${tool.name} input schema (${error.message}). ` + + `Exposing it with no parameters — checks touching this tool are not meaningful.` + ); + input_schema = { type: 'object', properties: {} }; + } + if (input_schema.type !== 'object') { + console.warn( + `WARNING: ${tool.name} input schema converted to type "${input_schema.type}", not ` + + `"object". Exposing it with no parameters.` + ); + input_schema = { type: 'object', properties: {} }; + } + defs.push({ + name: tool.name, + description: String(tool.description || '').slice(0, 8000), + input_schema + }); + } + return defs; +} + +const styleBuilder = allTools.find((t) => t.name === 'style_builder_tool'); + +/** Layers a style declares itself, i.e. excluding anything the import supplies. */ +const ownLayers = (style) => + (style?.layers || []).filter((l) => l.type !== 'background'); + +/** The call that actually ships a style, whichever tool the model routed through. */ +const uploadCall = (calls) => + calls.find( + (c) => c.name === 'create_style_tool' || c.name === 'update_style_tool' + ); + +const EMISSIVE = { + fill: 'fill-emissive-strength', + line: 'line-emissive-strength', + circle: 'circle-emissive-strength' +}; + +const RD_YL_GN = ['#d7191c', '#1a9641', '#a6d96a', '#fdae61', '#ffffbf']; + +const CASES = [ + { + id: 'dark-mode', + prompt: + 'Build me a dark map style for a nighttime food delivery app. I want the base map to be dark so the delivery info on top stands out.', + checks: { + 'uses style_builder_tool': (c) => c.called('style_builder_tool'), + 'base_style is standard': (c) => { + const call = c.first('style_builder_tool'); + return !call || (call.input.base_style ?? 'standard') === 'standard'; + }, + 'dark via lightPreset night': (c) => + c.first('style_builder_tool')?.input?.standard_config?.lightPreset === + 'night', + 'does not use a dark base style': (c) => + !c.calls.some((x) => + /dark-v11|navigation-night/.test(JSON.stringify(x.input)) + ), + 'does not use global_settings.mode': (c) => + !c.calls.some((x) => x.input?.global_settings?.mode) + } + }, + { + id: 'custom-data-layer', + prompt: + 'I have a GeoJSON of delivery zone polygons and a route line. Put them on a Mapbox map. The map needs to work at night as well as during the day.', + // These checks deliberately inspect the style that would actually be uploaded, not the + // tool used to build it. The model is free to hand-author the layers; what matters is + // whether the style it ships is correct. + checks: { + // The capability is only worth having if the model finds it. Before custom_sources + // existed the builder rejected user data outright ("layer not found"), so the model + // had to hand-author the layers and upload them raw. + 'routes user data through custom_sources': (c) => { + const call = c.first('style_builder_tool'); + return !!call && !!call.input?.custom_sources; + }, + 'uploaded style is Standard-based': (c) => { + const call = c.uploaded(); + return !!call && Array.isArray(call.input?.style?.imports); + }, + 'every custom layer has a slot': (c) => { + const layers = ownLayers(c.uploaded()?.input?.style); + return layers.length > 0 && layers.every((l) => !!l.slot); + }, + 'fill/line/circle layers set emissive strength': (c) => { + const lit = ownLayers(c.uploaded()?.input?.style).filter( + (l) => EMISSIVE[l.type] + ); + return ( + lit.length > 0 && lit.every((l) => l.paint?.[EMISSIVE[l.type]] === 1) + ); + }, + 'route sets line-occlusion-opacity': (c) => { + const lines = ownLayers(c.uploaded()?.input?.style).filter( + (l) => l.type === 'line' + ); + return ( + lines.length > 0 && + lines.some((l) => l.paint?.['line-occlusion-opacity'] !== undefined) + ); + }, + 'mentions night visibility in its reasoning': (c) => + /emissive|night|light preset|lightPreset/i.test(c.text) + } + }, + { + id: 'config-first', + prompt: + 'On my existing Mapbox map the roads are too loud and the POI labels are cluttering everything. Quiet them down.', + checks: { + 'reaches for Standard config': (c) => + /standard_config|lightPreset|theme|showPointOfInterestLabels|setConfigProperty|setStyleImportConfigProperty/.test( + JSON.stringify(c.calls.map((x) => x.input)) + c.text + ), + 'uses a theme or POI toggle rather than new layers': (c) => + /faded|monochrome|showPointOfInterestLabels|densityPointOfInterestLabels/.test( + JSON.stringify(c.calls.map((x) => x.input)) + c.text + ), + 'does not create a new style for a config change': (c) => + !c.called('create_style_tool') + } + }, + { + id: 'diverging-ramp', + prompt: + 'Shade US counties by temperature anomaly — how far above or below the long-run average each one is. Give me the paint expression.', + checks: { + 'avoids a red-to-green ramp': (c) => + !RD_YL_GN.some((hex) => + (JSON.stringify(c.calls.map((x) => x.input)) + c.text) + .toLowerCase() + .includes(hex) + ), + 'uses a colorblind-safe diverging scheme': (c) => + /RdBu|PuOr|BrBG|#b2182b|#2166ac|#67a9cf|#ef8a62/i.test( + JSON.stringify(c.calls.map((x) => x.input)) + c.text + ) + } + }, + { + id: 'explicit-classic', + prompt: + 'I need a classic Mapbox style, not Standard — we are on an old GL JS version that does not support style imports. Build me a dark classic style with water, parks, roads and city labels.', + // The counterpart to 'dark-mode': everything else steers hard at Standard, so nothing + // checked that a caller who genuinely wants Classic gets a coherent Classic style rather + // than Standard advice applied to a base that ignores it. + checks: { + 'honours the explicit Classic request': (c) => { + const call = c.first('style_builder_tool'); + return !!call && (call.input.base_style ?? 'standard') !== 'standard'; + }, + // A Classic base authors nothing for you, so an empty layers array is an empty map. + 'lists the features it wants drawn': (c) => { + const layers = c.first('style_builder_tool')?.input?.layers ?? []; + const kinds = JSON.stringify(layers); + return ( + layers.length >= 4 && + /water/.test(kinds) && + /landuse|park/.test(kinds) && + /road/.test(kinds) && + /place_label/.test(kinds) + ); + }, + // slot is Standard-only and now rejected outright on Classic, so reaching for it means + // the model carried Standard guidance across the boundary. + 'does not pass Standard-only options': (c) => { + const call = c.first('style_builder_tool'); + if (!call) return false; + const layers = call.input?.layers ?? []; + return ( + !call.input?.standard_config && + !layers.some((l) => l.slot !== undefined) + ); + }, + 'gets dark from the base or global_settings, not lightPreset': (c) => { + const call = c.first('style_builder_tool'); + if (!call) return false; + return ( + /dark-v11|navigation-night/.test(call.input.base_style ?? '') || + call.input?.global_settings?.mode === 'dark' + ); + }, + 'ships a Classic style with no imports': (c) => { + const call = c.uploaded(); + return !!call && !call.input?.style?.imports; + } + } + }, + { + id: 'hide-basemap-feature', + prompt: + 'On my Mapbox Standard style, get rid of the points of interest entirely — I do not want any POI icons or labels on the map.', + // Omitting the layer hides nothing on Standard, so the only correct answer is the config + // toggle. The failure this guards is a style that looks finished and changed nothing. + checks: { + 'hides POIs through the Standard config toggle': (c) => + /showPointOfInterestLabels/.test( + JSON.stringify(c.calls.map((x) => x.input)) + c.text + ), + 'sets the toggle to false': (c) => { + const haystack = JSON.stringify(c.calls.map((x) => x.input)) + c.text; + return /showPointOfInterestLabels["']?\s*[:=]\s*false/.test(haystack); + }, + 'asks for the POI feature by name, not something adjacent': (c) => { + const call = c.first('style_builder_tool'); + if (!call) return true; + // Either route is correct: the toggle set directly, or `hide` on the POI layer, which + // the builder converts into that toggle. Anything else — recolouring the POIs, hiding + // some other layer, listing no POI layer at all — leaves them on the map. + if (call.input?.standard_config?.showPointOfInterestLabels === false) { + return true; + } + return (call.input?.layers ?? []).some( + (l) => l.action === 'hide' && /poi/i.test(l.layer_type ?? '') + ); + }, + 'uploaded style carries the config': (c) => { + const call = c.uploaded(); + if (!call) return true; + return ( + call.input?.style?.imports?.[0]?.config?.showPointOfInterestLabels === + false + ); + } + } + }, + { + id: 'create-style-routing', + prompt: + 'Create a brand new style in my Mapbox account for a public transit app and give me a preview link.', + checks: { + 'builds the JSON with style_builder_tool': (c) => + c.called('style_builder_tool'), + 'any uploaded style is Standard-based': (c) => { + const call = c.first('create_style_tool'); + if (!call) return true; // nothing uploaded yet is not a failure + return Array.isArray(call.input?.style?.imports); + }, + 'does not hand-author a background layer': (c) => { + const call = c.first('create_style_tool'); + if (!call) return true; + return !(call.input?.style?.layers || []).some( + (l) => l.type === 'background' + ); + } + } + } +]; + +const SYSTEM = `You are helping a developer build Mapbox maps. You have the Mapbox MCP DevKit tools available. Use them to do the work rather than only describing it. Explain your choices briefly.`; + +async function runCase(client, tools, testCase) { + const messages = [{ role: 'user', content: testCase.prompt }]; + const calls = []; + let text = ''; + let truncated = false; + + for (let turn = 0; turn < MAX_TURNS; turn++) { + // Thinking is left at each model's default (adaptive on every current model) rather than + // disabled. Disabling it made the eval both narrower and wrong-headed: `thinking: + // {type: "disabled"}` is rejected outright on Claude Fable 5, and on the Sonnet/Opus models + // it measurably reduces how readily the model reaches for tools — which is the only thing + // this eval scores. Set EVAL_EFFORT to trade thoroughness for cost. + const response = await client.messages.create({ + model: MODEL, + max_tokens: 4096, + system: SYSTEM, + tools, + messages, + ...(EFFORT ? { output_config: { effort: EFFORT } } : {}) + }); + + for (const block of response.content) { + if (block.type === 'text') text += `\n${block.text}`; + if (block.type === 'tool_use') { + calls.push({ name: block.name, input: block.input }); + } + } + + if (response.stop_reason !== 'tool_use') break; + // Still mid-conversation with no turns left: the checks below are about to score a partial + // transcript, so say so rather than let a turn-limit artifact read as a regression. + if (turn === MAX_TURNS - 1) { + truncated = true; + break; + } + + messages.push({ role: 'assistant', content: response.content }); + const results = []; + for (const block of response.content) { + if (block.type !== 'tool_use') continue; + let content; + if (block.name === 'style_builder_tool') { + // Feed back the tool's real output so the model can chain into create_style_tool. + const real = await styleBuilder.run(block.input); + content = (real.content?.[0]?.text ?? '').slice(0, 6000); + } else { + content = `Success. (Eval stub — no live Mapbox API call was made.)`; + } + results.push({ type: 'tool_result', tool_use_id: block.id, content }); + } + messages.push({ role: 'user', content: results }); + } + + const ctx = { + calls, + text, + called: (name) => calls.some((c) => c.name === name), + first: (name) => calls.find((c) => c.name === name), + uploaded: () => uploadCall(calls) + }; + + const results = {}; + for (const [label, check] of Object.entries(testCase.checks)) { + try { + results[label] = (await check(ctx)) === true; + } catch { + results[label] = false; + } + } + return { + id: testCase.id, + results, + truncated, + toolsUsed: calls.map((c) => c.name) + }; +} + +const client = new Anthropic(); +const tools = toolDefinitions(); +console.log( + `Model: ${MODEL}\nTools exposed: ${tools.map((t) => t.name).join(', ')}\n` +); + +// allSettled, not all: cases run concurrently, and one rate limit or transport error would +// otherwise reject the whole run and discard every other case's results with it. +const settled = await Promise.allSettled( + CASES.map((c) => runCase(client, tools, c)) +); +const outcomes = settled.map((result, i) => + result.status === 'fulfilled' + ? result.value + : { + id: CASES[i].id, + results: {}, + toolsUsed: [], + error: result.reason?.message || String(result.reason) + } +); + +let passed = 0; +let total = 0; +let errored = 0; +for (const outcome of outcomes) { + if (outcome.error) { + errored++; + // Its checks still count against the total, so a case that never ran cannot raise the score. + const testCase = CASES.find((c) => c.id === outcome.id); + total += Object.keys(testCase.checks).length; + console.log(`! ${outcome.id} did not run`); + console.log(` error: ${outcome.error}\n`); + continue; + } + const entries = Object.entries(outcome.results); + const casePassed = entries.filter(([, ok]) => ok).length; + passed += casePassed; + total += entries.length; + const mark = casePassed === entries.length ? '✓' : '✗'; + console.log( + `${mark} ${outcome.id} ${casePassed}/${entries.length}` + + (outcome.truncated ? ` (TRUNCATED at ${MAX_TURNS} turns)` : '') + ); + for (const [label, ok] of entries) { + console.log(` ${ok ? 'pass' : 'FAIL'} ${label}`); + } + console.log(` tools: ${outcome.toolsUsed.join(' → ') || '(none)'}\n`); +} + +const pct = total ? ((passed / total) * 100).toFixed(1) : '0.0'; +console.log( + `Total: ${passed}/${total} checks (${pct}%)` + + (errored ? ` — ${errored} case(s) failed to run` : '') +); + +const jsonFlag = process.argv.indexOf('--json'); +if (jsonFlag !== -1 && process.argv[jsonFlag + 1]) { + writeFileSync( + process.argv[jsonFlag + 1], + JSON.stringify({ model: MODEL, passed, total, pct, outcomes }, null, 2) + ); +} + +process.exit(passed === total ? 0 : 1); diff --git a/skills/README.md b/skills/README.md index 51b4c49..56d20f6 100644 --- a/skills/README.md +++ b/skills/README.md @@ -20,32 +20,47 @@ npx add-skill mapbox/mapbox-agent-skills --skill mapbox-cartography ### Available Skills -The mapbox-agent-skills repository includes **10 comprehensive skills**: +The mapbox-agent-skills repository includes **19 skills**: -**Migration & Platform:** +**Migration:** -- `mapbox-google-maps-migration` - Migrate from Google Maps to Mapbox GL JS +- `mapbox-google-maps-migration` - Migrate from Google Maps Platform to Mapbox GL JS - `mapbox-maplibre-migration` - Migrate from MapLibre GL JS to Mapbox -**Performance & Integration:** - -- `mapbox-web-performance-patterns` - Performance optimization for Mapbox GL JS -- `mapbox-web-integration-patterns` - Framework integration (React, Vue, Svelte, Angular, Next.js) - **Design & Styling:** -- `mapbox-cartography` - Map design principles and best practices -- `mapbox-style-patterns` - Common style patterns and layer configurations +- `mapbox-cartography` - Map design on the Standard style: config-first workflow, themes, light presets, slots, color, hierarchy, typography +- `mapbox-style-patterns` - Style recipes for typical scenarios (POI finders, real estate, data viz) - `mapbox-style-quality` - Style validation, accessibility, and testing +- `mapbox-data-visualization-patterns` - Choropleths, heat maps, 3D and data-driven styling -**Security:** +**Web:** -- `mapbox-token-security` - Access token security and best practices +- `mapbox-web-integration-patterns` - Framework integration (React, Vue, Svelte, Angular, Next.js) +- `mapbox-web-performance-patterns` - Performance optimization for Mapbox GL JS **Mobile:** - `mapbox-ios-patterns` - iOS integration with Swift, SwiftUI, UIKit - `mapbox-android-patterns` - Android integration with Kotlin, Jetpack Compose +- `mapbox-flutter-patterns` - Flutter integration, including iOS/Android platform setup + +**Search & Location:** + +- `mapbox-search-patterns` - Choosing the right search tool and parameters +- `mapbox-search-integration` - End-to-end search implementation workflow +- `mapbox-store-locator-patterns` - Store locators, restaurant finders, location search apps +- `mapbox-geospatial-operations` - Picking the right geospatial tool for the problem + +**MCP:** + +- `mapbox-mcp-devkit-patterns` - Using this server in AI coding assistants +- `mapbox-mcp-runtime-patterns` - Using the Mapbox MCP Server in AI apps and agent frameworks +- `mapbox-location-grounding` - Grounded, cited location answers from live data + +**Security:** + +- `mapbox-token-security` - Access token scopes, URL restrictions, and rotation ## How Skills Work with the MCP Server @@ -70,13 +85,30 @@ With MCP Server + Skills: 5. → Creates optimized, secure map ``` +### Where map-design decisions are defined + +`mapbox-cartography` is the source of truth for map-design doctrine. The tool descriptions, prompts +and resources in this server follow it rather than restating it, so guidance stays consistent whether +an agent has the skills installed or is working from the server's tool schemas alone. The rules that +most often show up as bugs: + +- Default to `mapbox://styles/mapbox/standard`; Classic styles only when explicitly asked for +- Change appearance through style-import config before adding layers +- Dark mode is `lightPreset: "night"` — not a different base style, not hand-authored dark colors +- Every custom layer sets an explicit `slot` +- Custom fill/line/circle layers set emissive strength `1`, or they vanish at dusk/night +- Routes set `line-occlusion-opacity` so 3D buildings don't hide them +- Never red→green or rainbow ramps for ordered data; use ColorBrewer RdBu / PuOr / BrBG + +If you change one of these in the server, change it in `mapbox-cartography` too — and vice versa. + ## Why Skills Moved to a Separate Repository **Benefits:** - ✅ **Dedicated maintenance**: Skills can be updated independently - ✅ **Better discoverability**: Easier to find and install via `npx add-skill` -- ✅ **Comprehensive collection**: 10 skills covering web, mobile, and migration +- ✅ **Comprehensive collection**: 19 skills covering design, web, mobile, search, and migration - ✅ **Community contributions**: Easier for community to contribute new skills - ✅ **Versioning**: Skills can be versioned independently from MCP server diff --git a/src/prompts/BuildCustomMapPrompt.ts b/src/prompts/BuildCustomMapPrompt.ts index b418d39..e7366f0 100644 --- a/src/prompts/BuildCustomMapPrompt.ts +++ b/src/prompts/BuildCustomMapPrompt.ts @@ -58,18 +58,31 @@ export class BuildCustomMapPrompt extends BasePrompt { Follow these steps to create and preview the styled map: 1. **Use the Style Builder** - - Use the style_builder_tool to create the themed map style - - Provide this prompt: "${stylePrompt}" - - The Style Builder will use AI to interpret your theme and create appropriate: - * Color schemes matching the theme - * Layer visibility and styling - * Typography and symbols - * Overall aesthetic + - Use the style_builder_tool to create the themed map style, expressing "${stylePrompt}" + - The tool takes structured input, not a description: \`base_style\`, a \`layers\` array, and the + config surface for whichever base you choose. Translate the theme into those parameters. + - **Keep \`base_style: "standard"\`.** Most of a theme is expressible through + \`standard_config\` alone, which is cheaper than authoring layers and survives basemap updates: + * \`theme\`: \`"faded"\` or \`"monochrome"\` — most of the way to any muted or minimal look + * \`lightPreset\`: \`"day"\`/\`"dawn"\`/\`"dusk"\`/\`"night"\` — **this is how a theme goes dark.** + Never switch to \`dark-v11\` or hand-author dark colors; the preset relights the whole scene + * \`color*\` overrides: \`colorWater\`, \`colorGreenspace\`, \`colorRoads\`, \`colorPlaceLabels\` + and the rest — this is how a theme gets its palette onto the basemap + * \`show*\` toggles: turn off what the theme doesn't want. On Standard this is the *only* way + to hide a basemap feature — omitting a layer hides nothing, because the import still draws it + - Add custom \`layers\` only for what the config cannot reach. Each one needs an explicit + \`slot\`, and fill/line/circle layers need emissive strength \`1\` or they vanish at + \`dusk\`/\`night\`. The tool sets emissive strength for you and reports the slot it inferred. + - Only use a Classic base (\`streets-v12\`, \`dark-v11\`, …) if the user explicitly asks for a + classic style. There you author every layer yourself: no \`slot\`, no \`lightPreset\`, no + config surface — appearance comes from \`global_settings\` and the order of \`layers\`. 2. **Review the generated style** - The style_builder_tool will return a complete Mapbox GL JS style specification - Review the style to ensure it matches the intended theme - Note any specific customizations made (colors, layers emphasized, etc.) + - Read the tool's auto-corrections: they name the slot it inferred, and the config property to + use when you asked for a layer that only overdraws the basemap 3. **Create the style** - Use create_style_tool to save the generated style to your Mapbox account @@ -111,21 +124,29 @@ Follow these steps to create and preview the styled map: * Suggestions for further customization if desired - Note: Validation warnings can be ignored for experimental maps -**Theme interpretation tips:** -- "Dark cyberpunk": Dark backgrounds, neon colors (cyan, magenta, purple), high contrast -- "Nature-focused": Earth tones, emphasize parks/forests/water, soften urban features -- "Minimal monochrome": Grayscale palette, simplified geometry, clean lines -- "Retro 80s neon": Bright colors, high saturation, bold typography`; +**Theme interpretation tips** (each one starts as \`standard_config\`, not as layers): +- "Dark cyberpunk": \`lightPreset: "night"\` for the dark scene, then neon \`colorRoads\` / + \`colorPlaceLabels\` (cyan, magenta, purple) for the high-contrast accents +- "Nature-focused": \`colorGreenspace\` and \`colorWater\` in earth tones, and + \`showPointOfInterestLabels: false\` to soften the urban clutter +- "Minimal monochrome": \`theme: "monochrome"\` does most of it; add \`showTransitLabels: false\` + and \`show3dObjects: false\` for the clean-lines part +- "Retro 80s neon": \`lightPreset: "dusk"\` with saturated \`color*\` overrides — and if you add + custom glow layers, they need emissive strength \`1\` or the dusk scene swallows them`; if (emphasis) { instructionText += `\n- Custom emphasis on "${emphasis}": Ensure these features are visually prominent`; } instructionText += `\n\n**Important notes:** -- The style_builder_tool is powered by AI and may need refinement +- Translating a theme into style parameters is a judgement call and may need refinement - Validation runs automatically to catch any issues in generated expressions - You can iterate on the style by making additional calls to style_builder_tool -- If the initial result doesn't match expectations, try refining the theme description +- If the initial result doesn't match expectations, adjust the config properties before reaching + for more layers — a theme that needs many custom layers usually needs a different \`theme\` or + \`lightPreset\` instead +- **Preview a dark or dusk theme at its own light preset.** A missing emissive strength looks + correct by day and invisible at night, so a day-time review never catches it - Consider the map's use case when choosing zoom levels and preview locations - For experimental maps, validation warnings can be addressed later`; diff --git a/src/prompts/CreateAndPreviewStylePrompt.ts b/src/prompts/CreateAndPreviewStylePrompt.ts index bb7d9ba..bcef150 100644 --- a/src/prompts/CreateAndPreviewStylePrompt.ts +++ b/src/prompts/CreateAndPreviewStylePrompt.ts @@ -32,7 +32,7 @@ export class CreateAndPreviewStylePrompt extends BasePrompt { { name: 'base_style', description: - 'Optional base style to start from (e.g., "streets-v12", "outdoors-v12", "light-v11", "dark-v11")', + 'Optional base style to start from. Defaults to "standard" (Mapbox Standard), the right choice for almost every new style. Pass a Classic style ("streets-v12", "outdoors-v12", "light-v11", "dark-v11") only when the user explicitly asks for one. For a dark map, keep "standard" and set lightPreset to "night" instead of passing "dark-v11".', required: false }, { @@ -51,7 +51,7 @@ export class CreateAndPreviewStylePrompt extends BasePrompt { getMessages(args: Record): PromptMessage[] { const styleName = args['style_name']; const styleDescription = args['style_description']; - const baseStyle = args['base_style'] || 'streets-v12'; + const baseStyle = args['base_style'] || 'standard'; const previewLocation = args['preview_location']; const previewZoom = args['preview_zoom'] || '12'; @@ -80,26 +80,62 @@ Follow these steps carefully: } instructionText += `\n - Base the style on Mapbox ${baseStyle} - - You can start with a basic style like: + - Build the style JSON with \`style_builder_tool\` (base_style: "${baseStyle}") rather than + hand-authoring it.`; + + // The two targets are configured through different surfaces, so guidance for one is at best + // noise on the other: slots and lightPreset do nothing on Classic, and a hand-authored + // background layer sits on top of the basemap on Standard. + if (baseStyle === 'standard') { + instructionText += ` Don't hand-write a \`background\` layer and a raw streets-v8 + source — on Standard the basemap arrives through the import, and a hand-rolled background + sits on top of it. + - Reach for appearance changes in this order: + 1. \`standard_config\` (theme, lightPreset, show/hide toggles, color overrides) + 2. custom layers in an explicit \`slot\`, only for data the basemap doesn't already carry + - A Standard style starts out as an import, not a layer list: \`\`\`json { "version": 8, "name": "${styleName}", - "sources": { - "mapbox": { - "type": "vector", - "url": "mapbox://mapbox.mapbox-streets-v8" - } - }, - "layers": [ + "imports": [ { - "id": "background", - "type": "background", - "paint": { "background-color": "#f0f0f0" } + "id": "basemap", + "url": "mapbox://styles/mapbox/standard", + "config": { "theme": "default", "lightPreset": "day" } } - ] + ], + "sources": {}, + "layers": [] } \`\`\` + - For a dark map, set \`config.lightPreset\` to \`"night"\`. Don't switch to \`dark-v11\` or + hand-author dark colors — the preset relights the whole basemap coherently. + - Every custom layer needs an explicit \`slot\`, and fill/line/circle layers need + emissive strength \`1\`, or they go nearly invisible under the dusk/night presets. + - To hide something the basemap draws, use the \`standard_config\` \`show*\` toggle. Leaving + the layer out of your own \`layers\` array hides nothing — the import still draws it.`; + } else { + instructionText += ` + - This is a **Classic** style, so the Standard advice does not apply here. \`slot\` is + rejected, there is no \`imports\` array and no \`lightPreset\`: a Classic style is a layer + stack you order yourself over a \`background\` layer. + - \`base_style: "${baseStyle}"\` is not a style import and does **not** reproduce that style — + the builder authors the stack, and the base name only decides light vs dark and whether + satellite imagery goes underneath. **Every feature you want drawn must be listed in + \`layers\`**; ask for water, landuse, roads, buildings and labels explicitly or the map will be + nearly empty. If the user wanted the real \`${baseStyle}\`, reference + \`mapbox://styles/mapbox/${baseStyle}\` in their map directly instead of building a new style. + - Appearance is set through \`global_settings\` (\`background_color\`, \`label_color\`, \`mode\`), + not \`standard_config\`, which is rejected on Classic. + - Layer order is the order of the \`layers\` array: polygons first, then lines, then labels. + - Consider whether Standard would serve better before continuing. Standard is configured rather + than authored, needs no per-feature layer list, and gets basemap updates for free — for a dark + map in particular, \`base_style: "standard"\` with \`lightPreset: "night"\` is less work and + more coherent than a hand-authored Classic stack.`; + } + + instructionText += ` - Save the style ID from the response 4. **Generate preview link** diff --git a/src/prompts/DebugMapboxIntegrationPrompt.ts b/src/prompts/DebugMapboxIntegrationPrompt.ts index 00f1d89..740d15f 100644 --- a/src/prompts/DebugMapboxIntegrationPrompt.ts +++ b/src/prompts/DebugMapboxIntegrationPrompt.ts @@ -187,6 +187,8 @@ Analyze the error and provide specific solutions: 2. **Invalid layer type**: Check layer type matches source geometry 3. **Missing required property**: Verify all required layer properties are set 4. **Check style spec**: Read \`resource://mapbox-style-layers\` for layer requirements +5. **\`beforeId\` targeting a basemap layer fails on Standard**: those layer names belong to the + import and are not addressable from your style. Use \`slot\` instead — see Phase 3b `; } @@ -204,7 +206,8 @@ Analyze the error and provide specific solutions: 1. Verify style ID is correct 2. Check if custom layers are properly configured 3. Ensure zoom/center coordinates are valid -4. Review layer order and visibility +4. Review layer order and visibility — on a style that imports Standard this means \`slot\`, not + array order or \`beforeId\`; see Phase 3b **If getting rate limit errors:** 1. Check token usage in Mapbox dashboard @@ -215,7 +218,48 @@ Analyze the error and provide specific solutions: `; } - instructionText += `## Phase 4: Testing & Validation + instructionText += `## Phase 3b: Standard-Specific Rendering Failures + +Check whether the style has an \`imports\` array (use retrieve_style_tool if you have the style ID). +**Skip this phase entirely if it does not** — none of it applies to a Classic style, where you order +layers yourself and nothing is lit. + +Where the style does import Standard, these are the failures that throw no error and log nothing: +the style is valid, the layer is in the JSON, and the map still looks wrong. + +1. **"My layer isn't visible"** — check emissive strength first + - \`fill-\`, \`line-\` and \`circle-emissive-strength\` default to \`0\`, so the lit scene shades + the layer into shadow. Under \`lightPreset: "dusk"\` or \`"night"\` it goes nearly invisible + - Set them to \`1\` on every custom fill, line and circle layer + - Tell-tale: it looks right by day and disappears at night. If the app switches presets by clock + time, this reproduces only in the evening + - \`symbol\` layers are fine (icon/text emissive strength already default to \`1\`); + \`fill-extrusion\` is real 3D and should be lit; \`heatmap\` has no such property + +2. **"My layer covers the street labels"** — the layer has no \`slot\` + - No \`slot\` is not neutral placement: the layer draws above **every** basemap layer, labels + included + - \`bottom\` (below roads) for choropleths and rasters, \`middle\` (above roads, behind labels and + 3D buildings) for data overlays, \`top\` (above POI labels) for markers + +3. **"Part of my route disappears"** — \`line-occlusion-opacity\` defaults to \`0\`, which hides the + stretch running behind a 3D building. Set it to \`1\` + +4. **"I hid a basemap layer and it's still there"** — on Standard you cannot remove a layer that + belongs to the import. Use the \`show*\` config properties + (\`showPointOfInterestLabels\`, \`showPlaceLabels\`, \`showRoadLabels\`, \`show3dObjects\`, …) + +5. **"Dark mode only half applied"** — \`lightPreset: "night"\` relights the basemap but does **not** + recolor your own layers; they keep the paint colors you gave them. That is what emissive strength + is for. Hand-authoring dark colors instead of using the preset produces exactly this split + +6. **"\`setPaintProperty\` on a basemap layer does nothing"** — the layer belongs to the import. + Use \`setConfigProperty('basemap', 'colorWater', …)\` / \`setStyleImportConfigProperty\` instead + +Reproduce at the preset the app actually ships, and at \`night\` as well — items 1 and 3 are +invisible in a day-time review. + +## Phase 4: Testing & Validation Run these diagnostic checks: diff --git a/src/prompts/DesignDataDrivenStylePrompt.ts b/src/prompts/DesignDataDrivenStylePrompt.ts index b09df1c..6469f52 100644 --- a/src/prompts/DesignDataDrivenStylePrompt.ts +++ b/src/prompts/DesignDataDrivenStylePrompt.ts @@ -93,6 +93,7 @@ Use color to show values from low to high: \`\`\`json { "type": "fill", + "slot": "bottom", "paint": { "fill-color": [ "interpolate", @@ -104,13 +105,22 @@ Use color to show values from low to high: 75, "#08519c", // Medium-high: deep blue 100, "#08306b" // High values: darkest blue ], - "fill-opacity": 0.7 + "fill-opacity": 0.7, + "fill-emissive-strength": 1 } } \`\`\` **Adjust the breakpoints** (0, 25, 50, 75, 100) based on your actual data range. +**Why \`slot\` and \`fill-emissive-strength\` are there** (Standard styles): +- \`"slot": "bottom"\` keeps the choropleth above land and water but **below roads and labels**, so the + basemap stays readable through it. With no slot the layer lands above every basemap layer, street + labels included. Use \`"middle"\` for zone/geofence overlays that should sit above roads. +- \`"fill-emissive-strength": 1\` holds the fill at its authored color under \`dusk\` and \`night\`. It + defaults to \`0\`, letting the scene light the layer — so it falls into shadow and goes nearly + invisible on a night map. + `; } else if (colorScheme === 'diverging') { instructionText += `### Color by Value (Diverging) @@ -120,24 +130,33 @@ Use two colors to show deviation from a midpoint: \`\`\`json { "type": "fill", + "slot": "bottom", "paint": { "fill-color": [ "interpolate", ["linear"], ["get", "${propertyName}"], - 0, "#d7191c", // Low values: red - 25, "#fdae61", // Below average: orange - 50, "#ffffbf", // Average: yellow - 75, "#a6d96a", // Above average: light green - 100, "#1a9641" // High values: green + 0, "#b2182b", // Low values: dark red + 25, "#ef8a62", // Below average: salmon + 50, "#f7f7f7", // Average: near-white midpoint + 75, "#67a9cf", // Above average: light blue + 100, "#2166ac" // High values: dark blue ], - "fill-opacity": 0.7 + "fill-opacity": 0.7, + "fill-emissive-strength": 1 } } \`\`\` **Use when**: Showing deviation from a norm (e.g., temperature above/below average). +**This is ColorBrewer RdBu, not red→green, deliberately.** A red→yellow→green ramp (RdYlGn, the +"traffic light") is the most common colorblind failure in data visualization: roughly 1 in 12 men +cannot separate its endpoints, so the two extremes carrying all the meaning collapse together. Use +**RdBu**, **PuOr**, or **BrBG** for diverging data — all keep their endpoints distinct under +deuteranopia and protanopia. Put the neutral color at your data's actual midpoint, not at the +middle of the number range. + `; } else if (colorScheme === 'categorical') { instructionText += `### Color by Category @@ -147,6 +166,7 @@ Use distinct colors for different categories: \`\`\`json { "type": "fill", + "slot": "bottom", "paint": { "fill-color": [ "match", @@ -158,12 +178,19 @@ Use distinct colors for different categories: "category5", "#ff7f00", // Orange "#999999" // Default: gray ], - "fill-opacity": 0.7 + "fill-opacity": 0.7, + "fill-emissive-strength": 1 } } \`\`\` -**Replace** "category1", "category2", etc. with your actual category values. +**Replace** "category1", "category2", etc. with your actual category values. Always keep the final +fallback color — a \`match\` without one drops features whose value you didn't anticipate. + +This is ColorBrewer Set1, a qualitative palette. Red and green together is fine in a *categorical* +palette: the categories are unordered and each gets a legend entry. What to avoid is red→green as a +*ramp*, where the reader must judge position along a scale by hue alone. Keep categorical palettes +to 8 colors or fewer; past that they stop being tellable apart. `; } @@ -177,6 +204,7 @@ Use circle size to represent magnitude: \`\`\`json { "type": "circle", + "slot": "middle", "paint": { "circle-radius": [ "interpolate", @@ -191,11 +219,20 @@ Use circle size to represent magnitude: "circle-color": "#3182bd", "circle-opacity": 0.6, "circle-stroke-width": 1, - "circle-stroke-color": "#ffffff" + "circle-stroke-color": "#ffffff", + "circle-emissive-strength": 1 } } \`\`\` +\`circle-emissive-strength\` is the one most often left out, because circles are usually the +foreground data — cluster bubbles, proportional symbols, user-location dots. It defaults to \`0\` +like fill and line, so a bubble map that looks right at \`day\` dims out at \`night\`. + +**Scale circles by area, not radius.** Fed straight into \`circle-radius\`, a value of 100 looks ~4x +wider than 25 rather than 4x in area, so large values read as wildly exaggerated. Interpolate on +\`["sqrt", ["get", "${propertyName}"]]\` when the circle encodes magnitude proportionally. + `; } @@ -207,6 +244,7 @@ Show density and intensity using a heatmap: \`\`\`json { "type": "heatmap", + "slot": "middle", "paint": { "heatmap-weight": [ "interpolate", @@ -244,6 +282,13 @@ Show density and intensity using a heatmap: } \`\`\` +There is no \`heatmap-emissive-strength\` — heatmap layers aren't lit by the scene, so they hold their +color across all four presets on their own. Don't invent it; \`validate_style_tool\` will reject it. + +**\`heatmap-radius\` must grow with zoom**, as it does above. Radius is in screen pixels, so a fixed +value covers far more ground when zoomed out — a heatmap that reads correctly at z9 becomes one +undifferentiated blob at z2. + `; } @@ -267,34 +312,39 @@ Before finalizing the style, you need to know: Now create the data-driven style: 1. **Start with a base style** - - Use style_builder_tool to generate a base style - - Provide instructions like: "Create a ${colorScheme} map for visualizing ${dataDescription}" - -2. **Add your data source** - - If using GeoJSON, you'll add it as a source: - \`\`\`json - "sources": { - "data": { - "type": "geojson", - "data": "YOUR_GEOJSON_URL_OR_INLINE_DATA" - } - } - \`\`\` - - - If using Mapbox tileset: + - Use style_builder_tool with \`base_style: "standard"\` — Mapbox Standard is the default for new styles + - The tool takes structured parameters, not a description: \`base_style\`, \`standard_config\`, + \`custom_sources\` and a \`layers\` array + - **Quiet the basemap through \`standard_config\`, not by deleting layers.** A data map needs the + basemap to recede: set \`theme: "faded"\` or \`"monochrome"\` and turn off what competes with your + data (\`showPointOfInterestLabels: false\`, \`show3dObjects: false\`). Biggest readability win for + a choropleth, at the cost of one config property. + - For a dark data map set \`lightPreset: "night"\` — not \`dark-v11\`, and not hand-authored dark + colors. Your own layers do *not* follow the preset, which is why they need emissive strength \`1\`. + +2. **Add your data source through \`custom_sources\`** + - Declare it in the same style_builder_tool call, keyed by an id your layer then references + with \`source_id\`. Don't hand-author the \`sources\` block: layers built this way get their + slot, emissive strength and (on lines) \`line-occlusion-opacity\` set for you, which is + exactly what gets lost when the JSON is written by hand. \`\`\`json - "sources": { - "data": { - "type": "vector", - "url": "mapbox://your.tileset" - } + "custom_sources": { + "data": { "type": "geojson", "data": "YOUR_GEOJSON_URL_OR_INLINE_FEATURECOLLECTION" } } \`\`\` + - For a Mapbox tileset use \`{ "type": "vector", "url": "mapbox://your.tileset" }\`, and set + \`source_layer\` on the layer — the layer name inside the tileset. 3. **Add your data-driven layer** + - Point it at the source with \`source_id\`, and set \`render_type\` explicitly — it is required + for your own data, because geometry cannot be inferred from a URL or a tileset - Use the expression examples from Step 2 - Adjust breakpoints based on your actual data range - Choose appropriate layer type (fill, circle, heatmap, etc.) + - **Set \`slot: "bottom"\` on a choropleth.** The builder's default for your own data is + \`middle\` — right for a zone or geofence overlay, wrong for a fill that encodes a value, + which wants the road network reading over it. The builder reports the slot it inferred, so + check that line if you left it off. 4. **Create the style** - Use create_style_tool with: @@ -384,15 +434,27 @@ Show property values as labels: ✅ **DO:** - Use interpolate for smooth transitions (continuous data) - Use step for clear breaks (ranked/classified data) -- Use match for categorical data -- Test at different zoom levels +- Use match for categorical data, always with a fallback color +- Give every custom layer an explicit \`slot\` — \`bottom\` for choropleths, \`middle\` for overlays, + routes and custom POI layers, \`top\` for markers and active selections +- Set emissive strength \`1\` on every fill, line, and circle layer you add +- Add \`line-occlusion-opacity\` to routes so 3D buildings don't hide them +- Quiet the basemap via \`standard_config\` (\`theme: "faded"\`, POI labels off) before restyling data +- Test at different zoom levels, and at the \`night\` light preset as well as \`day\` - Ensure color contrasts are accessible (4.5:1 ratio) - Document your data property names and ranges ❌ **DON'T:** - Use too many color breaks (5-7 is usually enough) - Rely solely on color (add patterns or sizes for accessibility) -- Use red/green combinations (colorblind-unfriendly) +- **Use red→green ramps for ordered data** (RdYlGn / "traffic light") — the most common colorblind + failure. Use RdBu, PuOr, or BrBG for diverging data instead +- **Use rainbow ramps for ordered data** — hue has no natural order, so readers can't tell which end + is "more" without the legend, and the bands imply breaks that aren't in the data. Use a single- or + multi-hue sequential ramp +- Omit \`slot\` and let a data layer land above the street labels +- Leave fill/line/circle emissive strength at \`0\` and ship a map that goes blank at night +- Cap \`fill-opacity\` so low that a data-driven ramp can never reach full strength - Forget to handle null/undefined property values ## Step 8: Validate the Style diff --git a/src/prompts/PrepareStyleForProductionPrompt.ts b/src/prompts/PrepareStyleForProductionPrompt.ts index b2f26f5..268bc6e 100644 --- a/src/prompts/PrepareStyleForProductionPrompt.ts +++ b/src/prompts/PrepareStyleForProductionPrompt.ts @@ -126,17 +126,64 @@ Ensure text is readable (WCAG ${wcagLevel} compliance): level: "${wcagLevel}", fontSize: "normal" or "large" based on text-size } - - Note: If no halo color, check against both light (#f0f0f0) and dark (#333333) backgrounds + - Note: If no halo color, check against the actual basemap the text sits on: + * For a style importing Standard, the background depends on \`config.lightPreset\`: check against + a light surface (#f0f0f0) for \`day\`/\`dawn\`, a dark one (#333333) for \`night\`/\`dusk\`. Check the + one preset the style ships; if the app switches presets at runtime the text must pass against + **both** — a color tuned only for \`day\` will fail at \`night\`. + * For a Classic style, check against its \`background\` layer color. **Report accessibility results:** - Total text layers checked - Layers that pass WCAG ${wcagLevel} - Layers that fail with specific issues - Recommendations for failing layers +- Which light preset(s) the check was performed against + +## Step 5: Check Standard Style Layer Placement and Lighting + +Skip this step if the style has no \`imports\` array — slots and emissive strength only apply to +styles built on Mapbox Standard. Where it does import Standard, these checks catch failures that +expression validation and contrast checking cannot see: the style is valid JSON and still renders +wrongly. + +1. **Every custom layer needs an explicit \`slot\`:** + - List every layer in the style's own \`layers\` array (not the imported basemap's layers) + - Flag any layer with no \`slot\`. This is a defect, not a preference: it draws above **every** + basemap layer including street labels, so data covers the labels that make the map readable + - Check the slot is plausible for what the layer is: + * \`bottom\` — choropleths, rasters, terrain (below roads) + * \`middle\` — data overlays, zone fills, heatmaps, routes, custom POI layers + * \`top\` — markers, active selections + - A large \`fill\` layer in \`top\` is a likely mistake: it will cover the POI labels + +2. **Fill, line and circle layers need emissive strength:** + - For each custom \`fill\`, \`line\` or \`circle\` layer, check for + \`fill-emissive-strength\` / \`line-emissive-strength\` / \`circle-emissive-strength\` + - Flag any missing or \`0\`. They default to \`0\`, letting the scene light the layer, so it falls + into shadow and goes nearly invisible under the \`dusk\` and \`night\` presets + - The most common way a map that looked correct in review ships broken: it's invisible only at + certain times of day, so day-time testing never catches it + - Do **not** flag: \`symbol\` layers (icon/text emissive strength already default to \`1\`), + \`fill-extrusion\` (real 3D, should be lit by the scene), or \`heatmap\` (no such property) + +3. **Routes need occlusion opacity:** + - For \`line\` layers representing a route or path, check for \`line-occlusion-opacity\` + - Its default of \`0\` completely hides the part of the line behind 3D buildings + +4. **Verify at the dark preset:** + - If \`preview_style_tool\` is available, preview with \`lightPreset\` set to \`night\` + - Confirm custom data layers are visible and legible, not just present in the JSON + +**Report layer placement results:** +- Custom layers checked +- Layers missing \`slot\` (with the slot each one should have) +- Layers missing emissive strength +- Any layer whose slot looks wrong for its type ${ !skipOptimization - ? `## Step 5: Optimize the Style + ? `## Step 6: Optimize the Style Run optimization to improve performance and reduce file size: @@ -155,7 +202,7 @@ Run optimization to improve performance and reduce file size: - Percentage reduction - Optimizations applied (unused sources, duplicate layers, etc.) - Recommendation to use optimized version` - : '## Step 5: Style Optimization\n\nSkipped per user request.' + : '## Step 6: Style Optimization\n\nSkipped per user request.' } ## Final Step: Generate Quality Report @@ -189,8 +236,18 @@ Create a comprehensive deployment checklist: - Text layers: - Passing: - Failing: +- Checked against light preset(s): +## Standard Layer Placement and Lighting + +✓/✗ All custom layers have an explicit slot +- Custom layers: +- Missing slot: +✓/✗ All fill/line/circle layers set emissive strength +- Missing emissive strength: + + ${ !skipOptimization ? `## Optimization diff --git a/src/prompts/SetupMapboxProjectPrompt.ts b/src/prompts/SetupMapboxProjectPrompt.ts index b1a9945..517be58 100644 --- a/src/prompts/SetupMapboxProjectPrompt.ts +++ b/src/prompts/SetupMapboxProjectPrompt.ts @@ -40,7 +40,7 @@ export class SetupMapboxProjectPrompt extends BasePrompt { { name: 'style_theme', description: - 'Initial style theme: "light", "dark", "streets", "outdoors", "satellite" (default: "light")', + 'Initial style theme: "light", "dark", "streets", "outdoors", "satellite" (default: "light"). These are looks, not base styles — all but "satellite" are configurations of Mapbox Standard.', required: false } ]; @@ -118,12 +118,34 @@ Create a secret token for server-side operations: } const stepNumber = projectType === 'backend' ? 4 : 3; + // "light", "dark" and the rest read like Classic style names, and treating them that way is + // how a project starts life on dark-v11 when it wanted Standard at lightPreset "night". + const themeRecipes: Record = { + light: + 'base_style: "standard", standard_config: { lightPreset: "day" } — the default look', + dark: 'base_style: "standard", standard_config: { lightPreset: "night" }. **Not `dark-v11`**: the preset relights the whole scene, and the app can switch it at runtime without reloading the style', + streets: + 'base_style: "standard", standard_config: { theme: "default", showRoadLabels: true } — Standard already is a streets map', + outdoors: + 'base_style: "standard", standard_config: { theme: "default", showPedestrianRoads: true }, plus terrain in the app if the project needs it', + satellite: + 'base_style: "satellite-streets-v12" — the one theme that is genuinely a different base rather than a configuration, because style_builder_tool cannot add a raster imagery source to a Standard style (custom_sources takes GeoJSON and vector tilesets only). It is a Classic base, so you author every vector layer you want over the imagery. If the project wants imagery under an otherwise-Standard map, reference mapbox://styles/mapbox/standard-satellite in the app instead of building a style here' + }; + const themeRecipe = + themeRecipes[styleTheme] ?? + `base_style: "standard", with standard_config chosen to match "${styleTheme}"`; + instructionText += `## Step ${stepNumber}: Create Initial Map Style Create a starter map style for the project: -- Use style_builder_tool to generate a ${styleTheme} themed style: - * Provide a description like: "${styleTheme} theme for ${projectName}, optimized for ${projectType} use" - * Let the AI generate appropriate styling based on the theme +- Use style_builder_tool with structured parameters (it takes \`base_style\`, \`layers\` and a config + object — not a free-text description): + * For a "${styleTheme}" look: ${themeRecipe} + * \`base_style: "standard"\` is the right default. A "${styleTheme}" theme is a *configuration* of + Mapbox Standard, not a different base style — reach for \`standard_config\` first and add custom + \`layers\` only for data Standard doesn't carry + * Any custom layer needs an explicit \`slot\`, and fill/line/circle layers need emissive strength + \`1\` so they stay visible if the app ever switches to the dusk or night preset - Then use create_style_tool with: * name: "${projectName} - ${styleTheme.charAt(0).toUpperCase() + styleTheme.slice(1)}" * Use the style JSON generated by style_builder_tool diff --git a/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts b/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts index 5fb1c68..56605f5 100644 --- a/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts +++ b/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts @@ -65,6 +65,71 @@ export class MapboxStyleLayersResource extends BaseResource { '- `motorway_junction` - Highway exits', '- `housenum_label` - House numbers', '', + '## Placing Custom Layers on Mapbox Standard', + '', + 'Standard is the default base style, and it differs from Classic in two ways that break', + 'custom layers if ignored.', + '', + '### 1. Slots, not layer order', + '', + 'Mapbox owns the basemap layer order on Standard — you do not hand-order it, and `beforeId`', + 'against a basemap layer is unreliable because those names are internal to the import.', + 'Put every custom layer in a **slot** instead:', + '', + '| Slot | Position in the Standard stack | Put here |', + '| --- | --- | --- |', + '| `bottom` | Above land / landuse / water polygons, **below** roads | Choropleths, rasters, terrain |', + '| `middle` | Above roads and lines, **behind** 3D buildings and labels | Most data overlays — polygon fills, geofences, zone boundaries, heatmaps, routes, custom POI layers |', + '| `top` | Above POI labels, **behind** place and transit labels | Markers, active selections |', + '', + 'Omitting `slot` is not a neutral default: the layer lands **above every basemap layer, including', + 'street labels**. That is the most common mistake when adding layers to Standard.', + '', + '`slot` is a style-spec property, so the same value works on GL JS, Android, iOS and Flutter.', + '', + '### 2. Emissive strength, or your layer disappears at night', + '', + 'Standard is a lit 3D scene, and `fill-`, `line-` and `circle-emissive-strength` all default to', + '`0` — the scene lights the layer, so under the `dusk` and `night` presets it falls into shadow', + 'and goes nearly invisible.', + '', + '- Set emissive strength to `1` on every custom **fill**, **line** and **circle** layer.', + '- **Symbol layers need nothing**: `icon-` and `text-emissive-strength` already default to `1`,', + ' so icons and labels stay legible across all four presets on their own.', + '- **fill-extrusion** is genuinely 3D and should be lit by the scene — leave it alone.', + '- **heatmap** has no emissive-strength property at all; it is not lit by the scene.', + '- Routes should also set `line-occlusion-opacity` (default `0` hides the occluded part entirely).', + '', + 'The light preset does **not** recolor your own layers — they keep the paint colors you gave them.', + 'Emissive strength is what keeps those colors readable when the basemap goes dark.', + '', + '```json', + '{ "id": "zones", "type": "fill", "slot": "middle", "source": "zones",', + ' "paint": { "fill-color": "#7b61ff", "fill-opacity": 0.6, "fill-emissive-strength": 1 } }', + '', + '{ "id": "route", "type": "line", "slot": "middle", "source": "route",', + ' "paint": { "line-color": "#3b6df5", "line-width": 4, "line-emissive-strength": 1, "line-occlusion-opacity": 1 } }', + '```', + '', + '### 3. Prefer configuration over new layers', + '', + 'Before adding a layer to restyle something the basemap already draws, check whether Standard', + 'exposes it as a config property. `theme`, `lightPreset`, a `show*` toggle or a `color*` override', + 'on the import is cheaper, survives basemap updates, and cannot land in the wrong slot. Dark mode', + 'in particular is `lightPreset: "night"` — not a different base style, and not hand-authored colors.', + '', + "This is not only a preference. A layer you add cannot *replace* the basemap's own — it draws a", + 'second copy on top, which you then keep in sync by hand. And **you cannot hide a basemap layer', + 'at all** from the importing style: leaving it out of your `layers` array hides nothing, because', + 'the layer belongs to the import. `showPointOfInterestLabels`, `showPlaceLabels`,', + '`showRoadLabels`, `showTransitLabels`, `show3dBuildings` (or `show3dObjects` for every 3D', + 'object at once) and `showAdminBoundaries` are the only', + 'way. There is no toggle for water, landuse or the road network itself — make those recede with', + '`theme: "faded"`/`"monochrome"` and the `color*` overrides.', + '', + 'On a Classic style the opposite is true throughout: no import, no config surface, no slots, and', + 'nothing drawn unless you author the layer for it.', + '', '## Layer Types and Properties', '' ].join('\n') @@ -84,6 +149,7 @@ export class MapboxStyleLayersResource extends BaseResource { '- `fill-antialias` - Whether to antialias the fill (default: `true`)', '- `fill-translate` - Geometry translation [x, y] in pixels (default: `[0, 0]`)', '- `fill-translate-anchor` - Reference for translate: `map` or `viewport` (default: `map`)', + '- `fill-emissive-strength` - How much the fill lights itself rather than being lit by the scene, 0-1 (default: `0`). **Set to `1` on custom layers over Standard**, or it falls into shadow and goes nearly invisible under the `dusk`/`night` presets', '', '**No layout properties for fill layers**', '' @@ -108,6 +174,8 @@ export class MapboxStyleLayersResource extends BaseResource { '- `line-gradient` - Gradient along the line (requires `lineMetrics: true` in source)', '- `line-translate` - Geometry translation [x, y] in pixels (default: `[0, 0]`)', '- `line-translate-anchor` - Reference for translate: `map` or `viewport` (default: `map`)', + '- `line-emissive-strength` - How much the line lights itself rather than being lit by the scene, 0-1 (default: `0`). **Set to `1` on custom layers over Standard**, or it goes nearly invisible under the `dusk`/`night` presets', + '- `line-occlusion-opacity` - Opacity of the portion of the line behind 3D objects, 0-1 (default: `0`, i.e. completely hidden). **Set this on routes**, or the route vanishes where it passes behind 3D buildings', '', '**Layout properties:**', '- `line-cap` - Display of line ends: `butt`, `round`, `square` (default: `butt`)', @@ -205,6 +273,7 @@ export class MapboxStyleLayersResource extends BaseResource { '- `circle-translate-anchor` - Reference for translate: `map` or `viewport` (default: `map`)', '- `circle-pitch-scale` - Circle scaling: `map` or `viewport` (default: `map`)', '- `circle-pitch-alignment` - Circle alignment: `map` or `viewport` (default: `viewport`)', + '- `circle-emissive-strength` - How much the circle lights itself rather than being lit by the scene, 0-1 (default: `0`). **Set to `1` on custom layers over Standard**. Most often forgotten, because circles are usually the foreground data — cluster bubbles, proportional symbols, user-location dots — and they dim out at `night`', '', '**Layout properties:**', '- `circle-sort-key` - Sort key for circle ordering', @@ -309,34 +378,69 @@ export class MapboxStyleLayersResource extends BaseResource { '- Automatically determines the correct geometry type for each source layer', '- Applies appropriate paint properties based on the action (color, highlight, hide, show)', '- Generates proper filters from filter_properties', + '- Places layers in a slot and sets emissive strength on Standard styles', + '- Takes your own GeoJSON or tilesets through `custom_sources`', '- Provides helpful suggestions when layers are not recognized', '', '### Example Usage', '', + 'Recolor the basemap through the import config, and add layers only for what the config', + 'cannot reach — here a filtered road highlight, which no `color*` property can express:', + '', '```', 'style_builder_tool({', ' style_name: "Custom Style",', ' base_style: "standard",', + ' standard_config: {', + ' colorWater: "#0099ff",', + ' colorGreenspace: "#00ff00",', + ' showPointOfInterestLabels: false', + ' },', ' layers: [', ' {', - ' layer_type: "water",', - ' action: "color",', - ' color: "#0099ff"', - ' },', - ' {', - ' layer_type: "landuse",', - ' filter_properties: { class: "park" },', - ' action: "color",', - ' color: "#00ff00"', - ' },', - ' {', ' layer_type: "road",', ' filter_properties: { class: ["motorway", "trunk"] },', - ' action: "highlight"', + ' action: "highlight",', + ' slot: "middle"', + ' }', + ' ]', + '})', + '```', + '', + 'Asking for `layer_type: "water"` with `action: "color"` on Standard would work, but it draws', + "a *second* water fill over the basemap's own rather than restyling it — the config property", + 'is what retints the basemap itself. On a Classic base the reverse holds: there is no config', + 'surface, so every feature you want drawn must be listed in `layers`.', + '', + '### Your Own Data', + '', + 'The `layer_type` lookup covers Streets v8 basemap features only. Put your own data on the', + 'map with `custom_sources` plus `source_id`:', + '', + '```', + 'style_builder_tool({', + ' style_name: "Delivery",', + ' base_style: "standard",', + ' custom_sources: {', + ' zones: { type: "geojson", data: "https://example.com/zones.geojson" }', + ' },', + ' layers: [', + ' {', + ' layer_type: "Delivery zones",', + ' source_id: "zones",', + ' render_type: "fill",', + ' action: "color",', + ' color: "#7b61ff",', + ' slot: "middle"', ' }', ' ]', '})', - '```' + '```', + '', + '`render_type` is required for these layers — a URL or tileset gives the tool no geometry to', + 'inspect. Placement differs from basemap layers on purpose: a basemap fill (parks, water) goes', + 'in `bottom` under the roads, while a fill of your own data is an overlay and goes in `middle`.', + 'A choropleth is the exception — set `slot: "bottom"` explicitly so the roads read over it.' ].join('\n') ); diff --git a/src/tools/create-style-tool/CreateStyleTool.input.schema.ts b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts index ba7f950..1bba323 100644 --- a/src/tools/create-style-tool/CreateStyleTool.input.schema.ts +++ b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts @@ -11,7 +11,15 @@ export const CreateStyleInputSchema = z.object({ style: z .record(z.string(), z.any()) .describe( - 'Complete Mapbox Style Specification object. Must include: version (8), sources, layers. Optional: sprite, glyphs, center, zoom, bearing, pitch, metadata, etc. See https://docs.mapbox.com/mapbox-gl-js/style-spec/' + 'Complete Mapbox Style Specification object. Must include: version (8), sources, layers. ' + + 'For a Mapbox Standard style — the default choice for new styles — also include imports: ' + + '[{ id: "basemap", url: "mapbox://styles/mapbox/standard", config: { ... } }], with sources ' + + 'and layers holding only your own data. The basemap arrives through the import, so do not ' + + 'hand-author a background layer or copy basemap layers in. Omitting imports makes a Classic ' + + 'style, which has no config surface and no slots. Prefer generating this object with ' + + 'style_builder_tool, which handles imports, slots and emissive strength for you. ' + + 'Optional: sprite, glyphs, center, zoom, bearing, pitch, metadata, etc. ' + + 'See https://docs.mapbox.com/mapbox-gl-js/style-spec/' ) }); diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index f300b63..a08eea8 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -21,7 +21,19 @@ export class CreateStyleTool extends MapboxApiBasedTool< typeof MapboxStyleOutputSchema > { name = 'create_style_tool'; - description = 'Create a new Mapbox style'; + // The Standard import example lives on the `style` field's own description rather than being + // repeated here; the model gets both. + description = `Create a new Mapbox style from a complete style JSON. + +Uploads whatever JSON it is given — it does not build a style. Prefer style_builder_tool for that: +it defaults to Mapbox Standard and supplies what hand-authored styles miss, an explicit slot on +every custom layer and emissive strength so they survive the dusk/night presets. + +First check whether Standard's config already expresses the intent — theme, lightPreset, show* and +color* need no style to create or maintain. A dark map is lightPreset:'night', not a new style. + +A Standard style is an 'imports' entry, not a layer stack: no background layer, no basemap layers +copied in, and 'sources'/'layers' hold only your own data. Omitting imports makes a Classic style.`; readonly annotations = { readOnlyHint: false, destructiveHint: false, diff --git a/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts index a365dca..a42a5d6 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts @@ -7,7 +7,23 @@ const LayerConfigSchema = z.object({ layer_type: z .string() .describe( - 'Layer type from the resource (e.g., "water", "railways", "parks")' + 'Streets v8 feature to style, e.g. "water", "road", "landuse". With source_id set this ' + + 'is only a human-readable name for your own layer.' + ), + + source_id: z + .string() + .optional() + .describe( + 'Key of a custom_sources entry, to style YOUR OWN data instead of a Streets v8 feature. ' + + 'render_type is required when set, and the layer gets overlay placement.' + ), + + source_layer: z + .string() + .optional() + .describe( + 'Layer name inside a custom vector tileset. Required for custom_sources type "vector".' ), render_type: z @@ -23,20 +39,18 @@ const LayerConfigSchema = z.object({ .optional() .default('auto') .describe( - 'How to render this layer visually. Default "auto" chooses based on geometry type.\n' + - 'Override to achieve specific visual effects:\n' + - '• "line" - For outlines, borders, strokes (e.g., building outlines, road borders)\n' + - '• "fill" - For solid filled areas (e.g., solid color buildings, water bodies)\n' + - '• "fill-extrusion" - For 3D extrusions (e.g., 3D buildings)\n' + - '• "symbol" - For text labels or icons\n' + - '• "circle" - For dot visualization (e.g., POI dots, data points)\n' + - '• "heatmap" - For density maps (points only)\n' + - 'IMPORTANT: Use "line" for outlines even on polygon features like buildings.' + 'How to render the layer. "auto" picks from geometry. Use "line" for outlines and borders ' + + 'even on polygons (e.g. building outlines), "fill-extrusion" for 3D, "circle" for dots, ' + + '"symbol" for labels, "heatmap" for density. Required when source_id is set.' ), action: z .enum(['show', 'hide', 'color', 'highlight']) - .describe('What to do with this layer'), + .describe( + 'What to do with this layer. "hide" on Standard sets the matching standard_config toggle, ' + + 'since the basemap feature belongs to the import; water, landuse and roads have no toggle ' + + 'and are rejected. On Classic, and for a layer of your own, "hide" omits the layer.' + ), color: z .string() .optional() @@ -119,8 +133,11 @@ const LayerConfigSchema = z.object({ .enum(['bottom', 'middle', 'top']) .optional() .describe( - 'Layer slot for Mapbox Standard styles. Controls layer stacking order. ' + - 'Bottom: below most map features, Middle: between base and labels, Top: above all base map features (default)' + 'Where the layer sits in the Standard stack. Set it on every custom layer: omitting it is ' + + 'not a default, the layer draws above every basemap layer including street labels. ' + + 'bottom = below roads (choropleths, rasters); middle = above roads, behind labels and 3D ' + + '(most overlays, routes, geofences); top = above POI labels (markers, selections). ' + + 'Standard only — rejected on a Classic base, where you order the layers array instead.' ) }); @@ -141,23 +158,73 @@ export const StyleBuilderToolSchema = z.object({ ]) .default('standard') .describe( - 'Base style template. ALWAYS use "standard" as the default for all new styles. ' + - 'Standard style provides the best performance and modern features. ' + - 'Only use Classic styles (streets/light/dark/satellite/outdoors/navigation) when explicitly requested with "create a classic style" or when working with an existing Classic style.' + 'ALWAYS "standard" unless a Classic style is explicitly asked for. A Classic base is not ' + + 'an import and does not reproduce the named style — only the layers you list get drawn, ' + + 'and an empty layers array is rejected. The name sets light vs dark ("dark-v11", ' + + '"navigation-night-v1" and the satellite bases are dark) and whether mapbox.satellite ' + + 'imagery sits underneath. To use a named Classic style as it actually looks, reference ' + + 'mapbox://styles/mapbox/ in the map instead of building a style here — see ' + + 'https://docs.mapbox.com/map-styles/guides/classic-styles/' ), layers: z .array(LayerConfigSchema) .describe('Layer configurations based on the mapbox-style-layers resource'), + custom_sources: z + .record( + z.string(), + z.union([ + z.object({ + type: z.literal('geojson'), + data: z + .union([z.string(), z.record(z.string(), z.any())]) + .describe( + 'GeoJSON URL, or an inline FeatureCollection/Feature object' + ) + }), + z.object({ + type: z.literal('vector'), + url: z + .string() + .describe('Tileset URL, e.g. "mapbox://username.tilesetid"') + }) + ]) + ) + .optional() + .describe( + 'Your own GeoJSON or tilesets, keyed by an id a layer references via source_id — zones, ' + + 'routes, store locations, choropleth values. "composite" (and "satellite" on a satellite ' + + "base) are the basemap's own ids and are rejected." + ), + global_settings: z .object({ - background_color: z.string().optional().describe('Background/land color'), - label_color: z.string().optional().describe('Default label color'), - mode: z.enum(['light', 'dark']).optional().describe('Light or dark mode') + background_color: z + .string() + .optional() + .describe( + 'Background/land color. Classic only — on Standard use standard_config.colorLand.' + ), + label_color: z + .string() + .optional() + .describe( + 'Default text-color for symbol layers, beaten by a per-layer color. Classic only.' + ), + mode: z + .enum(['light', 'dark']) + .optional() + .describe( + 'Light or dark mode. Classic only: it recolors only the layers this tool emits, so it ' + + 'cannot darken a Standard basemap — use standard_config.lightPreset "night" there. ' + + 'Defaults to what the Classic base_style names, so set it only to override that.' + ) }) .optional() - .describe('Global style settings'), + .describe( + 'Appearance for Classic styles, overriding what base_style implies. Rejected on Standard.' + ), standard_config: z .object({ @@ -165,87 +232,96 @@ export const StyleBuilderToolSchema = z.object({ showPedestrianRoads: z .boolean() .optional() - .describe( - 'Show/hide the base pedestrian roads and paths from the Standard style' - ), + .describe('Show/hide pedestrian roads and paths'), showPlaceLabels: z .boolean() .optional() - .describe( - 'Show/hide the base place label layers from the Standard style' - ), + .describe('Show/hide place label layers'), showPointOfInterestLabels: z .boolean() .optional() - .describe( - 'Show/hide the base POI icons and text from the Standard style' - ), + .describe('Show/hide POI icons and text'), showRoadLabels: z .boolean() .optional() - .describe( - 'Show/hide the base road labels and shields from the Standard style' - ), + .describe('Show/hide road labels and shields'), showTransitLabels: z + .boolean() + .optional() + .describe('Show/hide transit icons and text'), + show3dObjects: z .boolean() .optional() .describe( - 'Show/hide the base transit icons and text from the Standard style' + 'Show/hide ALL 3D objects — buildings, trees, landmarks, facades. For buildings ' + + 'alone use show3dBuildings.' ), - show3dObjects: z + show3dBuildings: z .boolean() .optional() .describe( - 'Show/hide the base 3D objects like buildings and landmarks from the Standard style' + 'Show/hide 3D buildings, leaving trees and landmarks in place. What action:"hide" ' + + 'on a "building" layer sets.' ), + show3dTrees: z.boolean().optional().describe('Show/hide 3D trees only'), + show3dLandmarks: z + .boolean() + .optional() + .describe('Show/hide 3D landmarks only'), + show3dFacades: z + .boolean() + .optional() + .describe('Show/hide 3D facade detail, leaving building volumes'), showLandmarkIcons: z .boolean() .optional() - .describe('Show/hide the base landmark icons from the Standard style'), + .describe('Show/hide landmark icons'), showLandmarkIconLabels: z .boolean() .optional() - .describe( - 'Show/hide the base landmark icon labels from the Standard style' - ), + .describe('Show/hide landmark icon labels'), showAdminBoundaries: z .boolean() .optional() - .describe( - 'Show/hide the base administrative boundaries from the Standard style' - ), + .describe('Show/hide administrative boundaries'), + showIndoor: z + .boolean() + .optional() + .describe('Show/hide indoor maps (venue floorplans)'), + showIndoorLabels: z + .boolean() + .optional() + .describe('Show/hide indoor map labels'), showRoadsAndTransit: z .boolean() .optional() .describe( - 'Show/hide the base roads and transit networks from the Standard style (Standard-Satellite)' + 'Standard Satellite only, so REJECTED here — listed so passing it is an error rather ' + + 'than a silently dropped key. The road network cannot be toggled off on Standard.' ), // String configuration properties theme: z .enum(['default', 'faded', 'monochrome', 'custom']) .optional() - .describe('Theme for the base Standard style layers'), + .describe('Basemap theme'), 'theme-data': z .string() .optional() - .describe('Custom color theme for the base style via Base64 LUT image'), + .describe('Custom theme via Base64 LUT image'), lightPreset: z .enum(['dusk', 'dawn', 'day', 'night']) .optional() - .describe('Time-of-day lighting for the base Standard style'), - font: z - .string() - .optional() - .describe('Font family for the base Standard style text'), + .describe('Time-of-day lighting. "night" is how you do dark mode'), + font: z.string().optional().describe('Basemap font family'), colorModePointOfInterestLabels: z .string() .optional() - .describe('Color mode for the base POI labels'), + .describe('POI label color mode'), backgroundPointOfInterestLabels: z .string() .optional() - .describe('Background style for the base POI labels'), + .describe('POI label background style'), // Numeric configuration properties densityPointOfInterestLabels: z @@ -253,83 +329,89 @@ export const StyleBuilderToolSchema = z.object({ .min(1) .max(5) .optional() - .describe('Density of base POI labels (1-5, default 3)'), + .describe('POI label density (1-5, default 3)'), + fuelingStationModePointOfInterestLabels: z + .string() + .optional() + .describe('Fueling-station POI label display mode'), // Color override properties colorPlaceLabels: z .string() .optional() - .describe('Override color for the base place labels in Standard style'), - colorRoadLabels: z + .describe('Color for place labels'), + colorRoadLabels: z.string().optional().describe('Color for road labels'), + colorLand: z .string() .optional() - .describe('Override color for the base road labels in Standard style'), + .describe( + 'Color for the land surface — the Standard equivalent of a Classic background color' + ), colorGreenspace: z .string() .optional() - .describe( - 'Override color for the base greenspace areas in Standard style' - ), - colorWater: z + .describe('Color for greenspace areas'), + colorBuildings: z.string().optional().describe('Color for buildings'), + colorCommercial: z .string() .optional() - .describe( - 'Override color for the base water features in Standard style' - ), - colorAdminBoundaries: z + .describe('Color for commercial land-use areas'), + colorEducation: z .string() .optional() - .describe( - 'Override color for the base administrative boundaries in Standard style' - ), - colorPointOfInterestLabels: z + .describe('Color for education land-use areas'), + colorMedical: z .string() .optional() - .describe('Override color for the base POI labels in Standard style'), - colorMotorways: z + .describe('Color for medical land-use areas'), + colorIndustrial: z .string() .optional() - .describe( - 'Override color for the base motorways/highways in Standard style' - ), - colorTrunks: z + .describe('Color for industrial land-use areas'), + colorWater: z.string().optional().describe('Color for water features'), + colorAdminBoundaries: z + .string() + .optional() + .describe('Color for administrative boundaries'), + colorPointOfInterestLabels: z .string() .optional() - .describe('Override color for the base trunk roads in Standard style'), - colorRoads: z + .describe('Color for POI labels'), + colorMotorways: z .string() .optional() - .describe( - 'Override color for the base regular roads in Standard style' - ), + .describe('Color for motorways/highways'), + colorTrunks: z.string().optional().describe('Color for trunk roads'), + colorRoads: z.string().optional().describe('Color for regular roads'), colorBuildingHighlight: z .string() .optional() - .describe( - 'Override color for the base highlighted buildings in Standard style' - ), + .describe('Color for highlighted buildings'), colorBuildingSelect: z .string() .optional() - .describe( - 'Override color for the base selected buildings in Standard style' - ), + .describe('Color for selected buildings'), colorPlaceLabelHighlight: z .string() .optional() - .describe( - 'Override color for the base highlighted place labels in Standard style' - ), + .describe('Color for highlighted place labels'), colorPlaceLabelSelect: z .string() .optional() - .describe( - 'Override color for the base selected place labels in Standard style' - ) + .describe('Color for selected place labels'), + colorIndoorLabelHighlight: z + .string() + .optional() + .describe('Color for highlighted indoor labels'), + colorIndoorLabelSelect: z + .string() + .optional() + .describe('Color for selected indoor labels') }) .optional() .describe( - 'Configuration for the base Mapbox Standard style. These properties customize the underlying Standard style features - you can still add your own custom layers on top using the layers parameter. The Standard style provides a rich basemap that you can configure and enhance with additional layers.' + 'Config for the imported Mapbox Standard basemap. Standard-only: rejected on a Classic ' + + 'base_style, which takes global_settings instead.' ) }); diff --git a/src/tools/style-builder-tool/StyleBuilderTool.ts b/src/tools/style-builder-tool/StyleBuilderTool.ts index 0f093f5..0cbde52 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.ts @@ -30,6 +30,503 @@ type DynamicLayerDefinition = { commonFilters: string[]; }; +type Slot = 'bottom' | 'middle' | 'top'; + +/** Where a layer's data comes from, which changes where it belongs in the stack. */ +type LayerOrigin = 'basemap' | 'user'; + +/** + * What differs between a Standard style and a Classic one. + * + * Standard is an import plus custom layers in slots on a lit 3D scene, with a config surface + * for the basemap. Classic is a hand-authored layer stack over a background layer, with + * neither. Most appearance options therefore apply to exactly one of them. + * + * Collected here in place of nine scattered `isUsingStandard` checks — including a positional + * boolean threaded into layer creation, which is how wrong-target options like + * `global_settings.mode` looked supported on Standard while doing almost nothing. + */ +interface StyleTarget { + readonly kind: 'standard' | 'classic'; + readonly label: string; + /** Standard owns basemap order, so custom layers are placed by slot instead. */ + readonly usesSlots: boolean; + /** Only a lit scene needs emissive strength to keep custom layers visible. */ + readonly usesLighting: boolean; + /** Classic has no import to supply land colour, so it needs its own background. */ + readonly needsBackgroundLayer: boolean; + /** Options belonging to the other target, rejected with a pointer to the right one. */ + readonly foreignOptions: ReadonlyArray<{ + field: string; + key?: string; + path: string; + instead: string; + }>; +} + +const STANDARD_TARGET: StyleTarget = { + kind: 'standard', + label: 'Standard', + usesSlots: true, + usesLighting: true, + needsBackgroundLayer: false, + foreignOptions: [ + { + field: 'global_settings', + key: 'mode', + path: 'global_settings.mode', + instead: + 'only recolors custom layers, so it cannot darken a Standard basemap. Use `standard_config.lightPreset: "night"`, which relights the whole scene.' + }, + { + field: 'global_settings', + key: 'background_color', + path: 'global_settings.background_color', + instead: + 'has no effect: Standard supplies its own background through the import. `standard_config.colorLand` is the land colour on Standard; `colorWater`, `colorGreenspace` and `colorBuildings` retint the features drawn over it.' + }, + { + field: 'global_settings', + key: 'label_color', + path: 'global_settings.label_color', + instead: + 'has no effect on Standard. Use `standard_config.colorPlaceLabels`, `colorRoadLabels` or `colorPointOfInterestLabels`.' + } + ] +}; + +const CLASSIC_TARGET: StyleTarget = { + kind: 'classic', + label: 'Classic', + usesSlots: false, + usesLighting: false, + needsBackgroundLayer: true, + foreignOptions: [ + { + field: 'standard_config', + path: 'standard_config', + instead: + 'only applies to the Standard style, which is the one with a config surface. Either set `base_style: "standard"` or style the layers directly.' + } + ] +}; + +const resolveTarget = (baseStyle: string): StyleTarget => + baseStyle === 'standard' ? STANDARD_TARGET : CLASSIC_TARGET; + +/** + * What a named Classic base can be taken to mean, given the builder authors the layer stack + * rather than importing the named style. + * + * A Classic base is deliberately NOT an import: the style stays self-contained, and the layers + * the caller asks for are the layers there are. That means the builder cannot reproduce the + * named style — no per-base palette is available to it, and inventing one would attribute made-up + * cartography to a Mapbox style. So only what the base name states outright is honoured, which + * is light-vs-dark and whether the base is imagery. + * + * That is still the fix for the original defect: every Classic value produced the same light + * `#f8f4f0` vector map, so `dark-v11` was a light map and `satellite-v9` had no imagery. Bases + * within a group are now equivalent, which is honest — the builder has nothing that separates + * `dark-v11` from `navigation-night-v1`. + */ +const DARK_CLASSIC_BASES = new Set([ + 'dark-v11', + 'navigation-night-v1', + 'satellite-v9', + 'satellite-streets-v12' +]); + +/** Bases where the map is imagery, so raster tiles stand in for the background layer. */ +const IMAGERY_CLASSIC_BASES = new Set([ + 'satellite-v9', + 'satellite-streets-v12' +]); + +/** The Classic appearance actually in force, once base and caller are reconciled. */ +interface ClassicSettings { + readonly mode: 'light' | 'dark'; + readonly backgroundColor: string; + readonly labelColor?: string; + readonly imagery: boolean; +} + +/** + * The two land colours are the ones the tool has always used for light and dark; the base name + * only decides which. An explicit `global_settings` value beats both. + */ +function resolveClassicSettings( + baseStyle: string, + globalSettings: StyleBuilderToolInput['global_settings'] +): ClassicSettings { + const mode = + globalSettings?.mode ?? + (DARK_CLASSIC_BASES.has(baseStyle) ? 'dark' : 'light'); + + return { + mode, + backgroundColor: + globalSettings?.background_color ?? + (mode === 'dark' ? '#1a1a1a' : '#f8f4f0'), + labelColor: globalSettings?.label_color, + imagery: IMAGERY_CLASSIC_BASES.has(baseStyle) + }; +} + +/** The Classic styles guide, which lists every Classic style and the URL that references it. */ +const CLASSIC_STYLES_DOC = + 'https://docs.mapbox.com/map-styles/guides/classic-styles/'; + +/** + * What a general-purpose Classic map is expected to draw. + * + * Used to name the shortfall in a thin Classic stack, and to say what a complete one looks like. + * Not a hard stop past the empty case — a couple of layers over satellite imagery is a legitimate + * thing to build — but a stack missing these is more often an incomplete list than a deliberate one. + */ +const CLASSIC_STAPLE_LAYERS = [ + 'water', + 'landuse', + 'road', + 'building', + 'place_label' +]; + +/** + * Why an empty Classic build is a redirect rather than a style. + * + * A Classic base authors nothing, so `layers: []` produced a lone background layer and reported + * success — the "looks finished and did nothing" outcome every other hard stop here exists to + * prevent. It is also the shape a caller lands in having read `base_style: "dark-v11"` as "give me + * dark-v11", which is the likeliest thing they meant and the one thing this tool cannot do: it + * builds a new self-contained style, so using the real one is a reference rather than a build. + */ +function emptyClassicGuidance(baseStyle: string, imagery: boolean): string { + const options = [ + `**You want the real \`${baseStyle}\`** — its palette, road hierarchy and label treatment. ` + + `Reference \`mapbox://styles/mapbox/${baseStyle}\` in your map directly; there is no style ` + + `to create. This tool builds new styles, it is not a way to fetch an existing one. Every ` + + `Classic style and the URL for it: ${CLASSIC_STYLES_DOC}`, + `**You want a map you can configure** — use \`base_style: "standard"\`. ` + + (DARK_CLASSIC_BASES.has(baseStyle) + ? `For a dark one add \`standard_config: { lightPreset: "night" }\`, which relights the ` + + `whole scene instead of recolouring a hand-authored stack.` + : `\`standard_config\` (\`theme\`, \`lightPreset\`, \`show*\`, \`color*\`) restyles the ` + + `basemap without authoring a single layer.`) + ]; + + if (imagery) { + options.push( + `**You want imagery under an otherwise-Standard map** — reference ` + + `\`mapbox://styles/mapbox/standard-satellite\` in your map. This tool imports plain ` + + `Standard, and \`custom_sources\` takes GeoJSON and vector tilesets, not raster.` + ); + } + + options.push( + `**You do want a self-contained Classic stack** — then list every feature it should draw in ` + + `\`layers\`: ${CLASSIC_STAPLE_LAYERS.map((name) => `\`${name}\``).join(', ')} at a ` + + `minimum, plus whatever else the map needs.` + ); + + return ( + `**A Classic base draws nothing on its own, and no layers were listed.**\n\n` + + `\`base_style: "${baseStyle}"\` is not a style import — the builder authors the whole stack, ` + + `so an empty \`layers\` array means an empty map: ` + + (imagery + ? `satellite imagery with nothing over it.` + : `a background colour and nothing else.`) + + `\n\nOne of these is what you meant:\n` + + options.map((option) => `• ${option}`).join('\n') + + `\n\nNothing was generated.` + ); +} + +/** + * The Standard config toggle that hides a basemap feature, where one exists. + * + * `action: "hide"` works on Classic by omitting the layer, because the builder authors the + * whole stack there. On Standard the feature belongs to the import and keeps drawing, so + * omitting the layer hid nothing while the summary still reported it hidden. Standard's own + * toggle is the only thing that does hide it. + */ +const STANDARD_HIDE_TOGGLE: Record = { + poi_label: 'showPointOfInterestLabels', + place_label: 'showPlaceLabels', + transit_stop_label: 'showTransitLabels', + // show3dBuildings, not show3dObjects: the latter is the whole 3D group — buildings, trees, + // landmarks and facades — so hiding "building" through it also strips the trees and + // landmarks the caller never mentioned. show3dObjects stays available on standard_config + // for callers who do want all 3D off. + building: 'show3dBuildings', + admin: 'showAdminBoundaries' +}; + +/** + * `standard_config` properties that belong to a Standard style this tool does not build. + * + * The import URL here is always `mapbox://styles/mapbox/standard`, and `base_style` offers no + * Standard Satellite value, so a Satellite-only property lands in the import config and is + * ignored — the exact "setting silently did nothing" failure the target split exists to stop. + * Rejected rather than dropped, and rejected here rather than by removing it from the schema, + * because the schema strips unknown keys: a caller who sent it anyway would get silence. + */ +const STANDARD_SATELLITE_ONLY_CONFIG: Record = { + showRoadsAndTransit: + 'Standard Satellite only. On the Standard style this tool builds, the road network cannot ' + + 'be toggled off at all — `showRoadLabels: false` drops the labels and shields, ' + + '`showPedestrianRoads: false` the paths, and `theme: "faded"` with `colorRoads` makes the ' + + 'network recede without removing it.' +}; + +/** + * Why a basemap feature cannot be hidden on Standard, and what to reach for instead. + * + * Returned as a hard stop rather than a warning: a style that silently failed to hide what + * was asked looks finished and is not. + */ +function standardHideGuidance(layerType: string): string { + const alternatives: Record = { + road: + 'Standard has no toggle for the road network itself. `showRoadLabels: false` hides the ' + + 'labels and shields, `showPedestrianRoads: false` hides paths and pedestrian streets, and ' + + '`theme: "faded"` with `colorRoads` makes the network recede without removing it.', + water: + 'Water is part of the basemap on Standard. Use `standard_config.colorWater` to recolour ' + + 'it, or `theme: "faded"`/`"monochrome"` to make it recede.', + landuse: + 'Landuse is part of the basemap on Standard. Use `standard_config.colorGreenspace` to ' + + 'recolour it, or `theme: "faded"`/`"monochrome"` to make it recede.' + }; + + return ( + `**"${layerType}" cannot be hidden on a Standard style.**\n\n` + + (alternatives[layerType] ?? + `Standard exposes no \`show*\` toggle for "${layerType}", and a style importing Standard ` + + `cannot remove layers from the import. Configure the basemap instead — \`theme: "faded"\` ` + + `or \`"monochrome"\` recedes it as a whole, and the \`color*\` overrides retint individual ` + + `features.`) + + `\n\nHideable through \`standard_config\`: ${Object.entries( + STANDARD_HIDE_TOGGLE + ) + .map(([layer, toggle]) => `${layer} (\`${toggle}\`)`) + .join(', ')}.` + + `\n\nOn a Classic style \`action: "hide"\` works as you expected, because the builder authors ` + + `every layer there. Nothing was generated.` + ); +} + +/** + * The Standard config property that recolours a basemap feature the caller is about to + * redraw. + * + * Adding a Streets v8 layer over Standard does not restyle the basemap's own layer — it draws + * a second copy on top, which has to be kept in sync by hand and picks up defaults (a fill + * outline, an opacity) the basemap never had. Worth a nudge, not a rejection: an overdraw is + * the right answer when the recolour is filtered to a subset the config cannot express. + */ +const STANDARD_COLOR_CONFIG: Record = { + water: 'colorWater', + waterway: 'colorWater', + landuse: 'colorGreenspace', + landuse_overlay: 'colorGreenspace', + road: 'colorRoads', + admin: 'colorAdminBoundaries', + place_label: 'colorPlaceLabels', + poi_label: 'colorPointOfInterestLabels' +}; + +/** + * Where a layer belongs in the Standard stack when the caller didn't say. + * + * Origin matters as much as geometry: a basemap fill (parks, water) belongs under the road + * network, but a fill of the user's own data (zones, a choropleth) is an overlay and belongs + * above roads, behind labels. Subtle to get wrong — it renders either way, it just reads wrong. + */ +function inferSlot(layerType: string, origin: LayerOrigin): Slot | undefined { + if (origin === 'user') { + // Overlay placement: markers above POI labels, everything else above roads but + // behind labels and 3D buildings. A choropleth is the exception that can't be inferred — + // it wants 'bottom' so the road network reads over it — so the inferred slot is reported + // and the caller told to set 'bottom' when the fill encodes a data value. + // + // fill-extrusion is exempt for exactly the reason a basemap one is: it is real 3D geometry + // that the scene depth-sorts against the buildings around it, and any slot flattens it into + // the 2D stack. Whose data it is doesn't change that. + if (layerType === 'fill-extrusion') return undefined; + return layerType === 'symbol' ? 'top' : 'middle'; + } + switch (layerType) { + case 'fill': + case 'raster': + case 'hillshade': + return 'bottom'; + case 'line': + case 'circle': + case 'heatmap': + return 'middle'; + case 'symbol': + return 'top'; + default: + // fill-extrusion is real 3D geometry participating in the scene's depth, so it gets + // no slot rather than being forced under the roads. + return undefined; + } +} + +/** + * Emissive strength keeps a custom layer at its authored colour on a lit scene. + * + * fill/line/circle default to 0, so the scene lights them into shadow under the dusk and + * night presets. Omitted deliberately: symbol (icon/text emissive strength already default + * to 1), fill-extrusion (should be lit by the scene), heatmap (no such property). + */ +const EMISSIVE_PROPERTY: Record = { + fill: 'fill-emissive-strength', + line: 'line-emissive-strength', + circle: 'circle-emissive-strength' +}; + +/** + * Readable names for `standard_config` properties in the build summary. + * + * Only where the property name doesn't already read well — anything absent falls back to the key + * itself, so a property added to the schema shows up in the summary without needing a line here. + */ +const STANDARD_CONFIG_LABEL: Record = { + showPlaceLabels: 'Place labels', + showRoadLabels: 'Road labels', + showPointOfInterestLabels: 'POI labels', + showTransitLabels: 'Transit labels', + showPedestrianRoads: 'Pedestrian roads', + show3dObjects: '3D objects', + show3dBuildings: '3D buildings', + show3dTrees: '3D trees', + show3dLandmarks: '3D landmarks', + show3dFacades: '3D facades', + showLandmarkIcons: 'Landmark icons', + showLandmarkIconLabels: 'Landmark icon labels', + showAdminBoundaries: 'Admin boundaries', + showIndoor: 'Indoor maps', + showIndoorLabels: 'Indoor labels', + colorMotorways: 'motorways', + colorTrunks: 'trunks', + colorRoads: 'roads', + colorWater: 'water', + colorLand: 'land', + colorGreenspace: 'greenspace', + colorBuildings: 'buildings', + colorCommercial: 'commercial', + colorEducation: 'education', + colorMedical: 'medical', + colorIndustrial: 'industrial', + colorAdminBoundaries: 'admin boundaries', + colorPlaceLabels: 'place labels', + colorRoadLabels: 'road labels', + colorPointOfInterestLabels: 'POI labels', + colorBuildingHighlight: 'building highlight', + colorBuildingSelect: 'building select', + colorPlaceLabelHighlight: 'place label highlight', + colorPlaceLabelSelect: 'place label select', + colorIndoorLabelHighlight: 'indoor label highlight', + colorIndoorLabelSelect: 'indoor label select', + densityPointOfInterestLabels: 'POI density', + colorModePointOfInterestLabels: 'POI label color mode', + backgroundPointOfInterestLabels: 'POI label background', + fuelingStationModePointOfInterestLabels: 'Fueling station POI mode', + 'theme-data': 'Custom theme LUT', + font: 'Font' +}; + +/** + * `standard_config` properties that start with "color" but are not colours. + * + * `colorModePointOfInterestLabels` names a mode, not a value, so grouping it with the colour + * overrides by prefix alone would report a mode as a colour. + */ +const NON_COLOR_CONFIG = new Set(['colorModePointOfInterestLabels']); + +/** + * The default arm of a data-driven colour on one of the caller's own layers. + * + * A `match` without a fallback drops every feature whose value wasn't listed, so one is always + * emitted. Neutral grey rather than anything from the palette, so an unhandled category reads as + * "not classified" instead of blending in with a real class. + */ +const UNCLASSIFIED_COLOR = '#999999'; + +/** + * A colour value the style spec can actually parse. + * + * Bare hex ("7b61ff") arrives often enough to be worth handling, and left raw it fails validation + * at upload rather than at the point the mistake was made. Only something that is unambiguously + * bare hex is prefixed: a named colour ("red") or a functional one is already valid, and prefixing + * it would produce "#red". + */ +function normalizeColor(color?: string): string | undefined { + if (!color) return undefined; + return /^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) + ? `#${color}` + : color; +} + +/** + * A `filter` on one of the caller's own layers, as an expression the style spec accepts. + * + * The basemap path resolves an object filter against Streets v8 field metadata, which is what + * makes `{ class: "park" }` work there. Custom data has no such metadata, so this path used to + * assign the object straight to `layer.filter` — producing a style the spec rejects outright + * ("array expected, object found"), which surfaced only when `create_style_tool` uploaded it. + * Converted rather than rejected: the object form is the shape a caller carries over from a + * basemap layer in the same call, and its intent is unambiguous without any field metadata. + * + * Returns the filter to set, `null` for nothing to set, or a guidance string for a hard stop. + */ +function userDataFilter( + filter: NonNullable +): { filter: Filter; correction?: string } | string | null { + // Already an expression — the caller wrote the filter the spec wants, so leave it alone. + if (Array.isArray(filter)) { + return { filter: filter as Filter }; + } + + if (typeof filter !== 'object' || filter === null) { + return ( + `**\`filter\` must be an expression or a property object, not ${typeof filter} ` + + `(\`${JSON.stringify(filter)}\`).**\n\n` + + `A filter selects features, so it needs a property to test against. Either pass a style ` + + `expression — \`["==", ["get", "status"], "active"]\` — or the object shorthand, ` + + `\`{ "status": "active" }\`, which this tool converts to one. Nothing was generated.` + ); + } + + const clauses: unknown[] = []; + for (const [key, value] of Object.entries(filter)) { + if (value === undefined || value === null) continue; + clauses.push( + Array.isArray(value) + ? // Any of several values. `match` rather than a chain of `==` under `any`, matching the + // shape the basemap path emits so both kinds of layer read the same way. + ['match', ['get', key], value, true, false] + : ['==', ['get', key], value] + ); + } + + if (clauses.length === 0) return null; + + const expression = ( + clauses.length === 1 ? clauses[0] : ['all', ...clauses] + ) as Filter; + + return { + filter: expression, + correction: + `\`filter\` was given as a property object and converted to ` + + `\`${JSON.stringify(expression)}\`. The spec takes an expression, not an object — passing ` + + `one through would have produced a style that fails validation on upload. Values are ` + + `compared exactly; pass an expression yourself for anything else (ranges, \`has\`, \`!\`).` + }; +} + // Geometry types from Mapbox tilestats API for Streets v8 // This maps actual source-layer names to their geometry types const SOURCE_LAYER_GEOMETRY: Record< @@ -63,67 +560,47 @@ export class StyleBuilderTool extends BaseTool { openWorldHint: false, title: 'Build Mapbox Style JSON Tool' }; - description = `Generate Mapbox style JSON for creating new styles or updating existing ones. - -The tool intelligently resolves layer types and filter properties using Streets v8 data. -You don't need exact layer names - the tool automatically finds the correct layer based on your filters. - -BASE STYLES: -• standard: ALWAYS THE DEFAULT - Modern Mapbox Standard with best performance -• Classic styles: streets-v12/light-v11/dark-v11/satellite-v9/outdoors-v12/satellite-streets-v12/navigation-day-v1/navigation-night-v1 - Only use Classic when user explicitly says "create a classic style" or working with existing Classic style - -STANDARD STYLE CONFIG: -Use standard_config to customize the basemap: -• Theme: default/faded/monochrome -• Light: day/night/dawn/dusk -• Show/hide: labels, roads, 3D buildings -• Colors: water, roads, parks, etc. - -LAYER ORDERING: -• Layers are rendered in order - last layer in the array appears visually on top -• The 'slot' parameter is OPTIONAL - by default, layer order in the array determines visibility -• For Standard style, you can optionally use 'slot' to control placement: - - No slot (default): Above all existing layers in the style - - 'top': Behind Place and Transit labels - - 'middle': Between basemap and labels - - 'bottom': Below most basemap features - -LAYER RENDERING: -• render_type controls HOW to visualize the layer (line, fill, symbol, etc.) -• Most important: Use render_type:"line" for outlines/borders even on polygon features -• Default "auto" picks based on geometry, but override for specific effects: - - Building outlines → render_type:"line" (not fill!) - - Solid buildings → render_type:"fill" or "fill-extrusion" (3D) - - Road lines → render_type:"line" (auto works too) - - POI dots → render_type:"circle" - - Labels → render_type:"symbol" - -LAYER ACTIONS: -• color: Apply a specific color -• highlight: Make prominent -• hide: Remove from view -• show: Display with defaults - -AUTO-DETECTION: -The tool automatically finds the correct layer from your filter_properties. -Examples: -• { class: 'park' } → finds 'landuse' layer -• { type: 'wetland' } → finds 'landuse_overlay' layer -• { maki: 'restaurant' } → finds 'poi_label' layer -• { toll: true } → finds 'road' layer -• { admin_level: 0 } → finds 'admin' layer (for country boundaries) -• { admin_level: 1 } → finds 'admin' layer (for state/province boundaries) - -IMPORTANT LAYER NAMES: -• Use "admin" for all boundaries (countries, states, etc.) -• Use "building" (singular, not "buildings") -• Use "road" for all streets, highways, paths - -If a layer type is not recognized, the tool will provide helpful suggestions showing: -• All available source layers from Streets v8 -• Which fields are available in each layer -• Examples of how to properly specify layers and filters`; + // Kept to the rules that change the shape of a call. Everything reference-shaped — the slot + // table, the emissive-strength explanation, the Streets v8 field lists, worked examples — lives + // in resource://mapbox-style-layers, and the per-field detail in this tool's input schema, which + // the model is given alongside this text. Repeating it here cost ~5k characters on every + // request to this server, styling task or not. + description = `Generate Mapbox style JSON. Resolves layer types and filters against Streets v8, so +approximate layer names work — { class: 'park' } finds 'landuse', { maki: 'cafe' } finds 'poi_label'. +Use "admin" for all boundaries, "building" (singular), "road" for all streets. Unrecognized types +come back with the available layers and fields. See resource://mapbox-style-layers for slots, +emissive strength, layer properties and worked examples. + +TARGET — base_style decides which options apply, and the wrong ones are REJECTED, not ignored: +• 'standard' is the default and almost always right. Takes standard_config; slots; lit scene +• Classic (streets-v12/light-v11/dark-v11/satellite-*/outdoors-v12/navigation-*) only when a + classic style is explicitly asked for. Takes global_settings; no config surface, no slots +• A Classic base is NOT an import and does not reproduce the named style — this tool authors the + stack, so EVERYTHING you want drawn must be listed in 'layers', and an empty list is REJECTED. + To use the named style itself, reference mapbox://styles/mapbox/ in the map rather than + building anything here. The base name only sets light vs dark and whether satellite imagery goes + underneath; bases within a group are equivalent + +ON STANDARD, CONFIGURE BEFORE YOU LAYER: +The basemap belongs to the import and this tool cannot reach into it, so a Streets v8 layer draws a +SECOND copy over the basemap's own. standard_config restyles the basemap itself: +• theme 'faded'/'monochrome' — fastest way to make your data pop +• lightPreset 'night' — THIS is dark mode. Not dark-v11, not global_settings.mode +• show* toggles to hide what competes with your data; color* to retint water/roads/parks/labels +• action:'hide' on Standard sets the matching toggle (poi_label→showPointOfInterestLabels, + place_label, transit_stop_label, building→show3dBuildings, admin→showAdminBoundaries). Water, + landuse and the road network have no toggle, so 'hide' on those is rejected — make them recede + with theme and color* instead. On Classic, 'hide' just omits the layer + +YOUR OWN DATA — custom_sources (the layer_type lookup covers Streets v8 only, not your data): +• custom_sources: { zones: { type: 'geojson', data: } }, then + layer: { layer_type: 'Zones', source_id: 'zones', render_type: 'fill', color: '#7b61ff' } +• render_type is REQUIRED here; for type 'vector' also set source_layer. 'composite' (and + 'satellite' on a satellite base) are reserved and rejected +• Color by value as on any layer: 'expression' for a ramp, or property_based + property_values + for a category match ('color' becomes the fallback arm) +• These get overlay placement, emissive strength and line-occlusion-opacity set for you. Override + with slot:'bottom' on a choropleth, so the road network reads over it`; constructor() { super({ inputSchema: StyleBuilderToolSchema }); @@ -134,7 +611,13 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho ): Promise { try { const result = this.buildStyle(input); - const { style, corrections, layerHelp, availableProperties } = result; + const { + style, + corrections, + layerHelp, + availableProperties, + standardConfig + } = result; // If we need layer help, return guidance to the model if (layerHelp) { @@ -179,10 +662,10 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho **Name:** ${input.style_name} **Base:** ${input.base_style || 'standard'} **Layers Configured:** ${input.layers.length} -${input.standard_config ? `**Standard Config:** ${Object.keys(input.standard_config).length} properties set` : ''} +${standardConfig ? `**Standard Config:** ${Object.keys(standardConfig).length} properties set` : ''} ${correctionsMessage} ${propertiesMessage} -${this.generateSummary(input)} +${this.generateSummary(input, result)} **Generated Style JSON:** \`\`\`json @@ -215,6 +698,14 @@ ${JSON.stringify(style, null, 2)} corrections: string[]; layerHelp?: string; availableProperties?: Record; + /** `standard_config` as generated, including toggles resolved from `hide` actions. */ + standardConfig?: Record; + /** Which `standard_config` toggle each hidden layer resolved to, by index in `layers`. */ + hideToggles: Map; + /** The Streets v8 source layer each basemap layer resolved to, by index in `layers`. */ + resolvedSourceLayers: Map; + /** The reconciled Classic appearance, or null on Standard. */ + classic: ClassicSettings | null; } { const layers: Layer[] = []; const allCorrections: string[] = []; @@ -224,29 +715,167 @@ ${JSON.stringify(style, null, 2)} > = {}; // Apply default base_style if not specified const baseStyle = input.base_style || 'standard'; - const isUsingStandard = baseStyle === 'standard'; - - // Only add background layer for non-Standard styles - // Standard style provides its own background through imports - if (!isUsingStandard) { - const bgColor = - input.global_settings?.background_color || - (input.global_settings?.mode === 'dark' ? '#1a1a1a' : '#f8f4f0'); - - const backgroundLayer: Layer = { - id: 'background', - type: 'background', - paint: { - 'background-color': bgColor - } - }; + const target = resolveTarget(baseStyle); + + // What each Classic base implies, reconciled with anything the caller set explicitly. + // Standard takes none of it — the import supplies the basemap. + const classic = + target.kind === 'classic' + ? resolveClassicSettings(baseStyle, input.global_settings) + : null; + + // A `hide` on Standard becomes a basemap config toggle, since the feature belongs to the + // import. Filled in as each layer is resolved, and read back by the summary so a `hide` + // is reported as the toggle it actually set. + const standardConfig: Record = { + ...(input.standard_config ?? {}) + }; + const hideToggles = new Map(); + // The source layer each basemap layer resolved to, so the summary can describe a layer by + // the name the style actually uses rather than by the string that was passed in. + const resolvedSourceLayers = new Map(); + const reportedHideToggles = new Set(); + + // Layer ids are derived from what the layer draws — the source layer plus its filter, or the + // custom source id plus its render type — so two layers over the same feature collide: a + // filtered pair off one GeoJSON source, or two unfiltered layers of the same basemap feature. + // Duplicate ids are invalid per the style spec, and the collision was silent. + const usedLayerIds = new Set(); + const uniqueLayerId = (id: string): string => { + if (!usedLayerIds.has(id)) { + usedLayerIds.add(id); + return id; + } + let suffix = 2; + while (usedLayerIds.has(`${id}-${suffix}`)) suffix++; + const unique = `${id}-${suffix}`; + usedLayerIds.add(unique); + return unique; + }; - layers.push(backgroundLayer); + /** + * A build that stopped with guidance instead of a style. + * + * Every one of these is a hard stop rather than a warning: the shared failure mode is a + * style that looks finished and did not do what was asked. + */ + const guidance = (layerHelp: string) => ({ + style: {} as MapboxStyle, + corrections: [], + layerHelp, + availableProperties: {}, + hideToggles, + resolvedSourceLayers, + classic + }); + + // Reject the other target's options before generating anything, so a caller never + // walks away believing a setting took effect when it was quietly dropped. + const foreign = StyleBuilderTool.assertTargetOptions(input, target); + if (foreign) { + return guidance(foreign); + } + + // A Classic base authors nothing, so no layers is no map — a background layer reported as a + // style built successfully. Stopped rather than built, because the likeliest reading of + // `base_style: "dark-v11"` is "give me dark-v11", which is a reference to an existing style + // rather than anything this tool can produce. Standard is exempt: a config-only style with no + // layers of its own is the normal shape there, since the import supplies the map. + if (target.kind === 'classic' && input.layers.length === 0) { + return guidance( + emptyClassicGuidance(baseStyle, classic?.imagery ?? false) + ); + } + + // Same rule one level down: a property of a *different Standard style* is as inert as a + // Classic option on Standard, and gets the same treatment rather than being passed through + // into the import config where nothing would read it. + const satelliteOnly = Object.keys(input.standard_config ?? {}).filter( + (key) => key in STANDARD_SATELLITE_ONLY_CONFIG + ); + if (satelliteOnly.length > 0) { + return guidance( + `**Not available on the Standard style this tool builds: ${satelliteOnly + .map((key) => `\`${key}\``) + .join(', ')}.**\n\n` + + satelliteOnly + .map( + (key) => `• \`${key}\` — ${STANDARD_SATELLITE_ONLY_CONFIG[key]}` + ) + .join('\n') + + `\n\nThis tool imports \`mapbox://styles/mapbox/standard\`, which has no such config ` + + `property, so setting it would have done nothing. Nothing was generated.` + ); + } + + // The builder's own source ids. A caller's source keyed the same way would replace the + // basemap's rather than sit beside it — silently, because custom sources are merged last — + // leaving every layer that referenced the id pointed at the wrong data. + const reservedSourceIds = [ + 'composite', + ...(classic?.imagery ? ['satellite'] : []) + ]; + const collisions = Object.keys(input.custom_sources ?? {}).filter((id) => + reservedSourceIds.includes(id) + ); + if (collisions.length > 0) { + return guidance( + `**Reserved source id${collisions.length > 1 ? 's' : ''} in \`custom_sources\`: ` + + `${collisions.map((id) => `\`${id}\``).join(', ')}.**\n\n` + + `This ${target.label} style already declares ${reservedSourceIds + .map((id) => `\`${id}\``) + .join(' and ')} for the basemap` + + `${classic?.imagery ? ' and its satellite imagery' : ''}, and \`custom_sources\` is ` + + `merged last — so your source would replace it and every layer built from the ` + + `basemap would read your data instead. Rename the entr${collisions.length > 1 ? 'ies' : 'y'} ` + + `(and the matching \`source_id\`) to something of your own, such as ` + + `${collisions.map((id) => `\`my-${id}\``).join(', ')}. Nothing was generated.` + ); + } + + // Classic has no import to supply land colour, so it needs its own background layer — + // unless the base is imagery, where the raster tiles are what the layers sit on. + if (target.needsBackgroundLayer && classic) { + layers.push( + classic.imagery + ? { + id: uniqueLayerId('satellite'), + type: 'raster', + source: 'satellite' + } + : { + id: uniqueLayerId('background'), + type: 'background', + paint: { + 'background-color': classic.backgroundColor + } + } + ); } // Build each configured layer - for (const config of input.layers) { - if (config.action === 'hide') continue; + for (const [index, config] of input.layers.entries()) { + // A layer bound to the caller's own source skips the Streets v8 lookup — the + // geometry lives in their data, not in the basemap. + if (config.source_id) { + // `hide` on your own data is omission on either target. The layer is yours to leave + // out rather than the import's to remove, so none of the Standard config surface + // applies — reaching for a `show*` toggle here would be advice about the basemap. + if (config.action === 'hide') continue; + + const userLayer = this.createUserDataLayer( + config, + input, + target, + allCorrections, + uniqueLayerId + ); + if (typeof userLayer === 'string') { + return guidance(userLayer); + } + layers.push(userLayer); + continue; + } // Determine the source layer for this config let sourceLayer = config.layer_type; @@ -272,22 +901,69 @@ ${JSON.stringify(style, null, 2)} } } - // If still no match, return helpful information + // If still no match, return helpful information. Reached for a `hide` too: an + // unrecognised layer type is an unrecognised layer type whatever the action, and the + // suggestion list is the useful answer rather than a verdict about Standard. if (!layerDef) { - const helpMessage = this.generateLayerHelp(config); - return { - style: {} as MapboxStyle, - corrections: [], - layerHelp: helpMessage, - availableProperties: {} - }; + return guidance(this.generateLayerHelp(config)); + } + + resolvedSourceLayers.set(index, sourceLayer); + + // `hide` is answered against the *resolved* source layer, so a name the tool had to + // work out from filter_properties gets the same answer as every other action would. + if (config.action === 'hide') { + if (target.kind === 'standard') { + const toggle = STANDARD_HIDE_TOGGLE[sourceLayer]; + if (!toggle) { + return guidance(standardHideGuidance(sourceLayer)); + } + if (standardConfig[toggle] === true) { + allCorrections.push( + `• \`standard_config.${toggle}\` was set to true and "${sourceLayer}" was also ` + + `asked to be hidden. The hide won — the toggle is now false. Drop one of the ` + + `two so the intent is unambiguous.` + ); + } + // Reported once per toggle. Two layers hiding the same feature is one decision, + // and repeating the line reads like two separate things happened. + if (!reportedHideToggles.has(toggle)) { + reportedHideToggles.add(toggle); + allCorrections.push( + `• Hiding "${sourceLayer}" on Standard set \`standard_config.${toggle}: false\`. ` + + `Omitting the layer would not have hidden it — the basemap draws it through the import.` + ); + } + standardConfig[toggle] = false; + hideToggles.set(index, toggle); + } + // On Classic, leaving the layer out of the stack is what hides the feature. + continue; + } + + // Redrawing a basemap feature on Standard stacks a second copy over the import's own. + // The config property restyles the basemap itself, so point at it before generating. + if ( + target.kind === 'standard' && + (config.action === 'color' || config.action === 'highlight') + ) { + const colorConfig = STANDARD_COLOR_CONFIG[sourceLayer]; + if (colorConfig) { + allCorrections.push( + `• Recolouring "${sourceLayer}" adds a second copy over the basemap's own — Standard ` + + `keeps drawing it underneath. \`standard_config.${colorConfig}\` retints the basemap ` + + `itself, which stays in sync with it. Keep the custom layer only if the recolour is ` + + `filtered to a subset the config cannot express.` + ); + } } const result = this.createLayer( layerDef, config, - input.global_settings, - isUsingStandard + classic, + target, + uniqueLayerId ); if (result.layer) { layers.push(result.layer); @@ -313,19 +989,75 @@ ${JSON.stringify(style, null, 2)} ); if (criticalError) { // Return helpful guidance for the model to retry with correct field - return { - style: {} as MapboxStyle, - corrections: [], - layerHelp: - criticalError + - '\n\n**Please retry with the corrected filter_properties.**', - availableProperties: {} - }; + return guidance( + criticalError + + '\n\n**Please retry with the corrected filter_properties.**' + ); } allCorrections.push(...result.corrections); } } + // Hiding a basemap feature and also drawing it is contradictory on its face, but it is a + // real technique on Standard: turn the basemap's own POIs off, draw your filtered subset + // over the top. So it is worth naming rather than rejecting — the same input is a mistake + // when the drawn layer is unfiltered, since that just reinstates what was hidden. + // + // Keyed by the hidden feature rather than by the layer that hid it, so two layers hiding + // one feature produce one line — the same de-duplication the toggle report needs. + const hiddenFeatures = new Map(); + for (const [index, toggle] of hideToggles) { + const name = resolvedSourceLayers.get(index); + if (name) hiddenFeatures.set(name, toggle); + } + for (const [hiddenLayer, toggle] of hiddenFeatures) { + const alsoDrawn = [...resolvedSourceLayers].filter( + ([otherIndex, name]) => + name === hiddenLayer && input.layers[otherIndex].action !== 'hide' + ); + if (alsoDrawn.length === 0) continue; + const unfiltered = alsoDrawn.some( + ([otherIndex]) => + !input.layers[otherIndex].filter_properties && + !input.layers[otherIndex].filter + ); + allCorrections.push( + `• "${hiddenLayer}" is hidden through \`standard_config.${toggle}\` and also drawn as a ` + + `custom layer. ` + + (unfiltered + ? `The custom layer has no filter, so it redraws what the toggle just hid — drop ` + + `one of the two.` + : `That is the right shape for showing a filtered subset of a feature you have ` + + `otherwise turned off; no change needed if it was deliberate.`) + ); + } + + // A thin Classic stack is usually an incomplete layer list rather than a deliberate one, and + // the shortfall is invisible in the output: the style is valid, it just draws less of the map + // than the caller pictured. Named rather than rejected, since a few layers over imagery — or a + // data-only style — are both legitimate. Imagery bases are exempt: there the photograph is the + // map, and the vector features are the addition. + if (target.kind === 'classic' && !classic?.imagery) { + const drawn = new Set( + [...resolvedSourceLayers] + .filter(([index]) => input.layers[index].action !== 'hide') + .map(([, name]) => name) + ); + const missing = CLASSIC_STAPLE_LAYERS.filter((name) => !drawn.has(name)); + if (missing.length > 0) { + allCorrections.push( + `• This Classic style draws nothing for ${missing + .map((name) => `"${name}"`) + .join( + ', ' + )}. A Classic base is not an import, so a feature absent from \`layers\` is ` + + `absent from the map. Add them if the map should show them — or reference ` + + `\`mapbox://styles/mapbox/${baseStyle}\` directly if what you wanted was that style's ` + + `own cartography (${CLASSIC_STYLES_DOC}).` + ); + } + } + // Note: We no longer automatically add layers that weren't explicitly requested // The user should specify all desired layers in the input @@ -358,12 +1090,10 @@ ${JSON.stringify(style, null, 2)} url: 'mapbox://styles/mapbox/standard' }; - // Add Standard style configuration if provided - if ( - input.standard_config && - Object.keys(input.standard_config).length > 0 - ) { - importConfig.config = input.standard_config; + // Add Standard style configuration if provided, including the toggles that `hide` + // actions resolved to. + if (Object.keys(standardConfig).length > 0) { + importConfig.config = standardConfig; } style.imports = [importConfig]; @@ -373,6 +1103,15 @@ ${JSON.stringify(style, null, 2)} type: 'vector' } }; + // The Streets sprite is deliberate here, not a Classic-style leftover: a sprite must + // match the icon vocabulary of the *data source*, not the imported basemap. The symbol + // layers this tool emits reference maki names — ["get", "maki"] off the Streets v8 maki + // field, or a literal like "marker-15" — and the Streets sprite is built for that data. + // + // Standard's icons aren't an option: an import is a separate scope, not addressable + // from the importing style's layers, and Standard exposes its own iconography rather + // than maki names. Per the spec a sprite is required once any layer uses icon-image or + // a *-pattern, and the root style's sprite is what serves the root style's layers. style.sprite = 'mapbox://sprites/mapbox/streets-v12'; style.glyphs = 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf'; style.projection = { name: 'globe' }; @@ -391,12 +1130,43 @@ ${JSON.stringify(style, null, 2)} url: 'mapbox://mapbox.mapbox-streets-v8,mapbox.mapbox-terrain-v2' } }; + // A satellite base is imagery, not a colour: without the raster source the "satellite" + // bases were indistinguishable from streets-v12 over a flat background. + if (classic?.imagery) { + (style.sources as Record).satellite = { + type: 'raster', + url: 'mapbox://mapbox.satellite', + tileSize: 256 + }; + } style.sprite = 'mapbox://sprites/mapbox/streets-v12'; style.glyphs = 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf'; style.layers = layers; } - return { style, corrections: allCorrections, availableProperties }; + // The caller's own sources sit alongside the basemap source under the ids their layers + // reference. Added after the target-specific block so neither branch can clobber them. + if (input.custom_sources) { + for (const [id, source] of Object.entries(input.custom_sources)) { + (style.sources as Record)[id] = + source.type === 'geojson' + ? { type: 'geojson', data: source.data } + : { type: 'vector', url: source.url }; + } + } + + return { + style, + corrections: allCorrections, + availableProperties, + standardConfig: + target.kind === 'standard' && Object.keys(standardConfig).length > 0 + ? standardConfig + : undefined, + hideToggles, + resolvedSourceLayers, + classic + }; } private findSourceLayerByFilterProperties( @@ -561,8 +1331,9 @@ ${JSON.stringify(style, null, 2)} private createLayer( layerDef: DynamicLayerDefinition, config: StyleBuilderToolInput['layers'][0], - globalSettings?: StyleBuilderToolInput['global_settings'], - isUsingStandard?: boolean + classic: ClassicSettings | null, + target: StyleTarget, + uniqueLayerId: (id: string) => string ): { layer: Layer | null; corrections: string[] } { // Generate a unique ID for the layer based on its properties let layerId = `${layerDef.id || config.layer_type}-custom`; @@ -576,23 +1347,43 @@ ${JSON.stringify(style, null, 2)} layerId = `${layerDef.id}-${filterKeys}`; } + // Two layers over the same feature with the same filter derive the same name, which is not + // a valid style. Suffixed rather than rejected: asking for the feature twice is legitimate. + layerId = uniqueLayerId(layerId); + const layer: Layer = { id: layerId, type: layerDef.type as Layer['type'] }; - // Add slot for Standard style if explicitly provided - if (isUsingStandard && config.slot) { - // User explicitly set the slot - respect their choice - // Available slots: - // - no slot (undefined): Above all existing layers in the style - // - 'top': Behind Place and Transit labels - // - 'middle': Between basemap and labels - // - 'bottom': Below most basemap features - layer.slot = config.slot; + // Slots only exist on styles that import Standard, where Mapbox owns the basemap order. + // A layer with no slot is not "unordered" — it lands above every basemap layer including + // street labels, almost never the intent — so infer one rather than leaving it off. + const slotCorrections: string[] = []; + if (target.usesSlots) { + if (config.slot) { + // Explicitly set - respect their choice. + layer.slot = config.slot; + } else { + const inferred = inferSlot(layerDef.type, 'basemap'); + if (inferred) { + layer.slot = inferred; + slotCorrections.push( + `• No slot given for "${layerId}" — inferred slot "${inferred}" from its ${layerDef.type} type. ` + + `Without a slot the layer would draw above the street labels. Set 'slot' explicitly to override.` + ); + } else { + // Not a gap in the inference: a fill-extrusion is real 3D geometry taking part in the + // scene's depth, so it is left unslotted on purpose. Reported all the same, because a + // layer coming back without the slot the docs insist on otherwise looks like a bug. + slotCorrections.push( + `• "${layerId}" was left without a slot deliberately — a ${layerDef.type} layer is 3D ` + + `geometry that the scene depth-sorts against the buildings around it, and a slot would ` + + `flatten it into the 2D stack. Set 'slot' explicitly only if you want that.` + ); + } + } } - // Note: If no slot is specified, the layer will appear above all existing layers - // Layers are rendered in order - last layer in the array appears visually on top // Add source configuration if (layerDef.sourceLayer) { @@ -610,17 +1401,7 @@ ${JSON.stringify(style, null, 2)} const paint: Record = {}; // Use the user-provided color if available, otherwise use defaults - let effectiveColor = config.color; - - // Ensure hex colors have # prefix - if ( - effectiveColor && - !effectiveColor.startsWith('#') && - !effectiveColor.startsWith('rgb') && - !effectiveColor.startsWith('hsl') - ) { - effectiveColor = '#' + effectiveColor; - } + let effectiveColor = normalizeColor(config.color); // Only provide a default color if none was specified if ( @@ -729,7 +1510,7 @@ ${JSON.stringify(style, null, 2)} const opacity = config.opacity !== undefined ? config.opacity - : isUsingStandard + : target.kind === 'standard' ? 0.75 : this.getDefaultOpacity(config.layer_type, layerDef.type); @@ -857,21 +1638,41 @@ ${JSON.stringify(style, null, 2)} // Ensure text has proper halo for readability if (!paint['text-halo-color']) { paint['text-halo-color'] = - globalSettings?.mode === 'dark' ? '#000000' : '#ffffff'; + classic?.mode === 'dark' ? '#000000' : '#ffffff'; } if (!paint['text-halo-width']) { paint['text-halo-width'] = 1.5; } } - // Adjust for dark mode - if (globalSettings?.mode === 'dark') { - if (layer.type === 'symbol') { - paint['text-color'] = paint['text-color'] || '#ffffff'; + // label_color and mode are Classic-only: Standard does labels through standard_config, + // and buildStyle rejects them there. Classic has no config surface, so apply them here. + if (layer.type === 'symbol' && classic) { + // Precedence, most specific first: this layer's color, then label_color, then the + // dark-mode default. All three must beat the generic per-property default, which has + // already put a literal text-color in `paint` — deferring to it is why label_color + // previously did nothing. + if (!config.color) { + if (classic.labelColor) { + paint['text-color'] = classic.labelColor; + } else if (classic.mode === 'dark') { + paint['text-color'] = '#ffffff'; + } + } + if (classic.mode === 'dark') { paint['text-halo-color'] = '#000000'; } } + // Keep custom fill/line/circle layers visible under dusk and night. These default to 0, + // which lets the scene light the layer into shadow. + if (target.usesLighting) { + const emissiveProp = EMISSIVE_PROPERTY[layer.type as string]; + if (emissiveProp && paint[emissiveProp] === undefined) { + paint[emissiveProp] = 1; + } + } + if (Object.keys(paint).length > 0) { layer.paint = paint; } @@ -885,22 +1686,26 @@ ${JSON.stringify(style, null, 2)} ) { const layout: Record = {}; - // Special handling for transit and POI layers - if ( - config.layer_type === 'transit' || - config.layer_type === 'poi_labels' - ) { + // Keyed on Streets v8 source layer names that actually exist. Previously keyed on + // 'transit', 'poi_labels', 'place_labels' and 'road_labels' — none of them a valid + // source layer, so every branch was unreachable and every symbol layer fell through + // to the generic defaults below and picked up a hardcoded "marker-15" icon. + const sourceLayerName = layerDef.sourceLayer || config.layer_type; + const iconField = this.iconField(sourceLayerName); + + if (iconField && layerDef.type === 'symbol') { + // Any iconified point layer — POIs by maki, transit and airports by their own field. + // Driven off the field so each feature gets its own icon. layout['text-field'] = ['get', 'name']; - layout['icon-image'] = [ - 'get', - config.layer_type === 'transit' ? 'network' : 'maki' - ]; + layout['icon-image'] = ['get', iconField]; layout['text-anchor'] = 'top'; layout['text-offset'] = [0, 0.8]; layout['icon-size'] = 1; layout['text-font'] = ['DIN Pro Regular', 'Arial Unicode MS Regular']; layout['text-size'] = 12; - } else if (config.layer_type === 'place_labels') { + } else if (sourceLayerName === 'place_label') { + // Deliberately no icon-image: place labels are text only, and the generic defaults + // would pin a marker on every city. layout['text-field'] = ['get', 'name']; layout['text-font'] = ['DIN Pro Medium', 'Arial Unicode MS Regular']; layout['text-size'] = [ @@ -912,21 +1717,21 @@ ${JSON.stringify(style, null, 2)} 18, 24 ]; - } else if (config.layer_type === 'road_labels') { + } else if (sourceLayerName === 'road' && layerDef.type === 'symbol') { + // Road labels ride along the line rather than sitting at a point. layout['symbol-placement'] = 'line'; layout['text-field'] = ['get', 'name']; layout['text-font'] = ['DIN Pro Regular', 'Arial Unicode MS Regular']; layout['text-size'] = 12; layout['text-rotation-alignment'] = 'map'; - } else if ( - 'layoutProperties' in layerDef && - Array.isArray(layerDef.layoutProperties) - ) { - // Default layout from definition + } else { + // Default layout from definition. for (const prop of layerDef.layoutProperties) { - if (prop.example !== undefined) { - layout[prop.property] = prop.example; - } + if (prop.example === undefined) continue; + // Never hand a symbol layer an icon it has no icon field for: the example value + // is a literal ("marker-15"), so applying it gives every layer the same pin. + if (prop.property === 'icon-image') continue; + layout[prop.property] = prop.example; } } @@ -935,7 +1740,342 @@ ${JSON.stringify(style, null, 2)} } } - return { layer, corrections: filterResult.corrections }; + return { + layer, + corrections: [...slotCorrections, ...filterResult.corrections] + }; + } + + /** + * Build a layer over one of the caller's own sources. + * + * Previously inexpressible: the tool only restyled Streets v8 basemap layers, so a user's + * zones, route or store points had to be hand-authored elsewhere and uploaded raw — which + * is exactly where slots and emissive strength got lost. + * + * Returns the layer, or a guidance string when the config cannot be honoured. + */ + private createUserDataLayer( + config: StyleBuilderToolInput['layers'][0], + input: StyleBuilderToolInput, + target: StyleTarget, + corrections: string[], + uniqueLayerId: (id: string) => string + ): Layer | string { + const sourceId = config.source_id as string; + const source = input.custom_sources?.[sourceId]; + if (!source) { + const known = Object.keys(input.custom_sources || {}); + return ( + `**Unknown source_id "${sourceId}".**\n\n` + + (known.length + ? `Declared sources: ${known.map((k) => `\`${k}\``).join(', ')}.` + : 'No `custom_sources` were declared.') + + `\n\nAdd the source to \`custom_sources\` first, then reference its key from the layer.` + ); + } + + // "auto" has nothing to work from: geometry can't be inferred from a URL or tileset. + // Ask rather than guess wrong and render nothing visible. + const renderType = + config.render_type && config.render_type !== 'auto' + ? config.render_type + : null; + if (!renderType) { + return ( + `**render_type is required for layers built from your own data.**\n\n` + + `Source "${sourceId}" has no geometry this tool can inspect, so it cannot choose for you. ` + + `Set render_type to one of: fill (areas/zones), line (routes/boundaries), ` + + `circle (points/bubbles), symbol (markers/labels), fill-extrusion (3D), heatmap (density).` + ); + } + + if (source.type === 'vector' && !config.source_layer) { + return ( + `**source_layer is required for vector source "${sourceId}".**\n\n` + + `Vector tilesets contain named source layers; GeoJSON does not. ` + + `Set \`source_layer\` to the layer name inside the tileset.` + ); + } + + // Two layers off one source with the same render type derive the same name — a filtered pair + // of zone fills, say — which is not a valid style. Suffixed rather than rejected. + const layer: Layer = { + id: uniqueLayerId(`${sourceId}-${renderType}`), + type: renderType as Layer['type'], + source: sourceId + }; + if (source.type === 'vector' && config.source_layer) { + layer['source-layer'] = config.source_layer; + } + + // Reported rather than applied silently, the same as a basemap layer: the inferred slot + // is a guess about intent, and for a fill it is the one guess that is often wrong. + if (target.usesSlots) { + if (config.slot) { + layer.slot = config.slot; + } else { + const inferred = inferSlot(renderType, 'user'); + if (inferred) { + layer.slot = inferred; + corrections.push( + `• No slot given for "${layer.id}" — inferred slot "${inferred}" from its ${renderType} ` + + `type, the overlay placement for your own data. ` + + (renderType === 'fill' + ? `Set \`slot: "bottom"\` instead if this fill encodes a data value (a choropleth), ` + + `so the road network still reads over it.` + : `Set 'slot' explicitly to override.`) + ); + } else { + // Same reasoning, and the same wording, as a basemap fill-extrusion: the layer is left + // unslotted on purpose, and that is reported because a layer coming back without the + // slot the docs insist on otherwise looks like a bug. + corrections.push( + `• "${layer.id}" was left without a slot deliberately — a ${renderType} layer is 3D ` + + `geometry that the scene depth-sorts against the buildings around it, and a slot would ` + + `flatten it into the 2D stack. Set 'slot' explicitly only if you want that.` + ); + } + } + } + + // Data-driven colour is the whole point of putting your own data on a map, and this path + // wrote the literal `color` only: `expression` and `property_based`/`property_values` were + // accepted by the schema and dropped. A choropleth therefore came out as a fill with no + // `fill-color` at all, which the spec renders as opaque black over the whole map. + const paint: Record = {}; + const colorProp = this.getColorProperty(renderType); + // Normalised through the same helper as a basemap layer: bare hex reaches this path just as + // readily, and it was the one path that passed it straight into the style. + const literalColor = normalizeColor(config.color); + if (renderType === 'heatmap') { + // A heatmap colours by density, not by feature, so `heatmap-color` takes a ramp over + // ["heatmap-density"] and cannot hold a literal. It is therefore absent from + // getColorProperty — which meant `color`, `expression` and `property_based` were all + // dropped here in silence, along with `opacity`, since heatmap-opacity was missing too. + this.applyHeatmapColor( + layer.id, + config, + literalColor, + paint, + corrections + ); + } else if (colorProp) { + const dataDriven = + config.expression !== undefined || + (config.property_based !== undefined && + config.property_values !== undefined); + if (dataDriven) { + if (config.expression === undefined && config.color === undefined) { + corrections.push( + `• "${layer.id}" colours by \`${config.property_based}\`, and \`color\` sets the ` + + `fallback for values not listed in \`property_values\` — ${UNCLASSIFIED_COLOR} was ` + + `used. A \`match\` with no fallback draws nothing at all for an unlisted value, so ` + + `there is always one; set \`color\` to choose it.` + ); + } + paint[colorProp] = this.generateExpression( + literalColor ?? UNCLASSIFIED_COLOR, + config, + 'color' + ); + } else if (literalColor) { + paint[colorProp] = literalColor; + } else { + // No colour is not the same as leaving the layer unstyled: every colour property here + // defaults to opaque black, so the layer lands as a black slab over the map. That is the + // same failure a `match` with no fallback has, which is why this path always emits one — + // and the basemap path has always filled in a colour it wasn't given. + paint[colorProp] = UNCLASSIFIED_COLOR; + corrections.push( + `• "${layer.id}" was given no \`color\`, so ${UNCLASSIFIED_COLOR} was used. The spec ` + + `default for \`${colorProp}\` is opaque black, which covers the map rather than ` + + `leaving the layer unstyled. Set \`color\`, or \`expression\` / \`property_based\` to ` + + `colour it by value.` + ); + } + } + + // Zoom ramps go through the shared helper rather than `generateExpression`, which would hand + // a numeric property the colour expression above. + const ramped = (value: number, kind: 'opacity' | 'width'): unknown => + config.zoom_based + ? this.zoomRamp( + value, + kind, + config.min_zoom ?? 10, + config.max_zoom ?? 18 + ) + : value; + + const opacityProp = this.getOpacityProperty(renderType); + if (opacityProp && config.opacity !== undefined) { + paint[opacityProp] = ramped(config.opacity, 'opacity'); + } + if (renderType === 'line' && config.width !== undefined) { + paint['line-width'] = ramped(config.width, 'width'); + } else if (config.width !== undefined) { + // `width` is a line width, and the schema says so, but it is accepted on every layer — so + // on a circle or a fill it was taken and dropped. Named rather than mapped to something + // adjacent: guessing `circle-radius` from a field called width is a different property. + corrections.push( + `• \`width\` was ignored on "${layer.id}": it sets \`line-width\`, and this is a ` + + `${renderType} layer. ` + + (renderType === 'circle' + ? `For dot size edit \`paint.circle-radius\` in the JSON below.` + : `Drop it, or use \`render_type: "line"\` if you wanted a stroke.`) + ); + } + + // `zoom_based` ramps opacity and width, and a colour ramp is what `expression` is for. With + // neither of those set it has nothing to act on, so it was accepted and did nothing — the + // silence this tool is otherwise careful to avoid. + if ( + config.zoom_based && + config.opacity === undefined && + !(renderType === 'line' && config.width !== undefined) + ) { + corrections.push( + `• \`zoom_based\` had no effect on "${layer.id}": it ramps \`opacity\` and, on a line, ` + + `\`width\` — neither was set. Set one of them, or pass \`expression\` with an ` + + `\`["interpolate", ["linear"], ["zoom"], …]\` ramp to vary the colour by zoom.` + ); + } + + // A fill-extrusion with no height renders nothing: fill-extrusion-height defaults to 0, so the + // layer is present, valid and flat — the same "looks finished, draws nothing" shape as a symbol + // layer with no text-field. The property holding the height cannot be read out of a URL or a + // tileset, so "height" is assumed, and the assumption reported rather than left to be found. + if (renderType === 'fill-extrusion') { + paint['fill-extrusion-height'] = ['get', 'height']; + corrections.push( + `• "${layer.id}" extrudes each feature by its \`height\` property. A fill-extrusion with ` + + `no \`fill-extrusion-height\` draws nothing at all — the default is 0 — and the tool ` + + `cannot read your data to find the right property. Edit ` + + `\`paint.fill-extrusion-height\` in the JSON below if your features name it differently, ` + + `or set it to a constant.` + ); + } + + if (target.usesLighting) { + const emissive = EMISSIVE_PROPERTY[renderType]; + if (emissive) paint[emissive] = 1; + + // A route is the canonical user line, and line-occlusion-opacity defaults to 0, so + // the stretch behind a 3D building disappears. Basemap roads are left alone — there, + // hiding behind a building is correct. + if (renderType === 'line') { + paint['line-occlusion-opacity'] = 1; + } + } + + // A symbol layer with neither text-field nor icon-image draws nothing whatsoever, so + // render_type: "symbol" produced a layer that was present, valid and invisible. The property + // holding the label cannot be read out of a URL or a tileset, so "name" is assumed — by far + // the most common — and the assumption is reported rather than left to be discovered. + // + // No icon-image: the sprite here is the Streets one, so a literal would resolve, but it would + // put the same generic pin on every feature. That is the defect the basemap path just lost. + if (renderType === 'symbol') { + layer.layout = { + 'text-field': ['get', 'name'], + 'text-font': ['DIN Pro Regular', 'Arial Unicode MS Regular'], + 'text-size': 12 + }; + corrections.push( + `• "${layer.id}" labels each feature from its \`name\` property. A symbol layer with no ` + + `\`text-field\` renders nothing at all, and the tool cannot see your data to find the ` + + `right property — edit \`layout.text-field\` in the JSON below if your features name it ` + + `differently, or use \`render_type: "circle"\` for plain points.` + ); + } + + if (config.filter !== undefined) { + const converted = userDataFilter(config.filter); + if (typeof converted === 'string') { + return converted; + } + if (converted) { + layer.filter = converted.filter; + if (converted.correction) corrections.push(`• ${converted.correction}`); + } + } + if (Object.keys(paint).length > 0) layer.paint = paint; + return layer; + } + + /** + * Reject options that belong to the other target. + * + * Silently ignoring a wrong-target option is how a caller ends up believing they set a + * dark theme when nothing happened — so reject, naming the option that does work. + * + * Enforced here rather than in the schema because tool registration reads + * `inputSchema.shape`, which only exists on a plain object schema: a discriminated union + * would break registration, and its `oneOf` output is the kind of complex schema some + * MCP clients mishandle. + */ + private static assertTargetOptions( + input: StyleBuilderToolInput, + target: StyleTarget + ): string | null { + const wrong = target.foreignOptions + .filter((opt) => { + const value = (input as unknown as Record)[opt.field]; + if (value === undefined || value === null) return false; + if (!opt.key) return true; + return (value as Record)[opt.key] !== undefined; + }) + .map((opt) => `\`${opt.path}\` ${opt.instead}`); + + // `slot` lives on the layer rather than at the top level, so the descriptor's field walk + // cannot see it. Left out, it was the one wrong-target option still being dropped in + // silence — and a caller carrying a Standard example over to Classic sets it every time. + if (!target.usesSlots) { + const slotted = input.layers + .map((layer, index) => ({ layer, index })) + .filter(({ layer }) => layer.slot !== undefined); + if (slotted.length > 0) { + wrong.push( + `\`slot\` (on ${slotted + .map(({ layer, index }) => `layers[${index}] "${layer.layer_type}"`) + .join( + ', ' + )}) only applies to styles that import Standard, where Mapbox owns the ` + + `basemap order. A ${target.label} style is a layer stack you order yourself, so ` + + `position layers by their order in \`layers\` instead — or set ` + + `\`base_style: "standard"\` to use slots.` + ); + } + } + + if (wrong.length === 0) return null; + return ( + `**Options that do not apply to a ${target.label} style.**\n\n` + + wrong.map((w) => `• ${w}`).join('\n') + + `\n\nRemove them, or change base_style. Nothing was generated.` + ); + } + + /** + * The per-feature field a source layer's icons come from, if it has one. + * + * Driving icon-image off the field gives each feature its own icon — a cafe gets the cafe + * glyph, a rail stop its network glyph. A literal gives every feature the same generic pin, + * which is what happened before: the layout defaults carry "marker-15" as their example + * value and it was applied to every symbol layer regardless. + */ + private iconField(sourceLayerName: string): 'maki' | 'network' | null { + const fields = STREETS_V8_FIELDS[ + sourceLayerName as keyof typeof STREETS_V8_FIELDS + ] as Record | undefined; + if (!fields) return null; + // maki wins where a layer has both. transit_stop_label carries both: maki is populated + // for every stop ("rail", "bus", "entrance"), while network holds a branded operator + // icon that is null for most features, leaving most stops with no icon at all. + if ('maki' in fields) return 'maki'; + if ('network' in fields) return 'network'; + return null; } private getColorProperty(layerType: string): string | null { @@ -958,21 +2098,107 @@ ${JSON.stringify(style, null, 2)} symbol: 'text-opacity', circle: 'circle-opacity', background: 'background-opacity', + // heatmap-opacity is an ordinary number like the rest, unlike heatmap-color. Its absence + // here is why `opacity` was dropped on every heatmap layer. + heatmap: 'heatmap-opacity', 'fill-extrusion': 'fill-extrusion-opacity' }; return opacityProps[layerType] || null; } - private generateSummary(input: StyleBuilderToolInput): string { - const parts: string[] = ['**Layer Configurations:**']; + /** + * Colour a heatmap built from the caller's own data. + * + * `heatmap-color` is the one colour property that is a ramp rather than a colour: the spec + * expects an expression over `["heatmap-density"]`, so a literal is invalid and a `match` on a + * feature property is meaningless — density is computed from the points, not read off one. That + * mismatch is why this path used to drop `color`, `expression` and `property_based` outright. + * + * Left unset the layer still draws, using the spec's blue-to-red default ramp, so nothing here + * is a hard stop: `color` becomes a single-hue ramp, `expression` passes through as the ramp + * itself, and the per-feature options are reported as belonging to `heatmap-weight` instead. + */ + private applyHeatmapColor( + layerId: string, + config: StyleBuilderToolInput['layers'][0], + literalColor: string | undefined, + paint: Record, + corrections: string[] + ): void { + if (config.expression !== undefined) { + // The caller supplying their own ramp is the escape hatch for everything this helper + // cannot express, so it goes through untouched. + paint['heatmap-color'] = config.expression; + } else if (literalColor) { + // Transparent at zero density so the basemap reads through where there are no points — + // the spec default starts transparent for the same reason, and an opaque stop at 0 would + // wash the whole layer in one colour. + paint['heatmap-color'] = [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(0, 0, 0, 0)', + 1, + literalColor + ]; + corrections.push( + `• "${layerId}" ramps from transparent to ${literalColor} by density. \`heatmap-color\` ` + + `takes an expression over \`["heatmap-density"]\` rather than a colour, so a single-hue ` + + `ramp was built from the \`color\` you gave. Pass \`expression\` with your own ` + + `\`["interpolate", ["linear"], ["heatmap-density"], …]\` for a multi-stop ramp.` + ); + } - for (const config of input.layers) { - const layerDef = this.createDynamicLayerDefinition( - config.layer_type, - config + if (config.property_based !== undefined) { + corrections.push( + `• \`property_based: "${config.property_based}"\` does not colour a heatmap: the colour ` + + `comes from point density, not from a feature property. To weight features by ` + + `\`${config.property_based}\`, edit \`paint.heatmap-weight\` in the JSON below — e.g. ` + + `\`["get", "${config.property_based}"]\`. \`expression\` sets the density ramp itself.` ); - const description = layerDef?.description || config.layer_type; + } + } + + /** + * The summary reports what the build produced, not what was passed in. + * + * Every resolved value comes from the build result: `standardConfig` carries the toggles + * `hide` actions resolved to, `hideToggles` says which toggle each layer got, + * `resolvedSourceLayers` gives the Streets v8 layer each one landed on, and `classic` is the + * already-reconciled Classic appearance. Re-deriving any of it from the input is how + * "poi_label: Hidden" appeared above a style that hid nothing, and how a layer passed as + * "pois" was described by a name the built style never used. + * + * `input` is still read for the caller's own intent — the action, the colour they asked for, + * the id of a custom source — none of which the build changes. + */ + private generateSummary( + input: StyleBuilderToolInput, + built: { + standardConfig?: Record; + hideToggles: Map; + resolvedSourceLayers: Map; + classic: ClassicSettings | null; + } + ): string { + const { standardConfig, hideToggles, resolvedSourceLayers, classic } = + built; + const parts: string[] = ['**Layer Configurations:**']; + + for (const [index, config] of input.layers.entries()) { + // The resolved name, so a layer the tool worked out from filter_properties is described + // as what it became. Falls back to the input only for a layer that never resolved — + // one of the caller's own, which has no Streets v8 definition to describe. + const resolved = resolvedSourceLayers.get(index) ?? config.layer_type; + // A layer bound to a custom source is described by the name the caller gave it. Its + // layer_type is free text, so looking it up reported a GeoJSON layer named "water" as + // "water layer (Polygon geometry)" — Streets v8 metadata for data that isn't Streets v8. + const layerDef = config.source_id + ? null + : this.createDynamicLayerDefinition(resolved, config); + const description = layerDef?.description || resolved; switch (config.action) { case 'color': @@ -983,87 +2209,80 @@ ${JSON.stringify(style, null, 2)} `• ${description}: Highlighted${config.color ? ` in ${config.color}` : ''}` ); break; - case 'hide': - parts.push(`• ${description}: Hidden`); + case 'hide': { + // A toggle exists only for a basemap feature on Standard. Your own layer is hidden + // by being left out, on either target, so it reports as a plain omission. + const toggle = hideToggles.get(index); + parts.push( + toggle + ? `• ${description}: Hidden via \`standard_config.${toggle}\`` + : `• ${description}: Hidden` + ); break; + } case 'show': parts.push(`• ${description}: Shown`); break; } } - if (input.global_settings?.mode) { - parts.push(`\n**Mode:** ${input.global_settings.mode}`); + if (classic) { + parts.push(`\n**Mode:** ${classic.mode}`); + parts.push( + classic.imagery + ? `**Base imagery:** mapbox.satellite raster` + : `**Background:** ${classic.backgroundColor}` + ); } - // Add Standard style configuration summary if present - if ( - input.standard_config && - Object.keys(input.standard_config).length > 0 - ) { + // Add Standard style configuration summary if present. + // + // Derived from what `standardConfig` actually holds rather than from a hand-written list of + // properties. The list only ever covered 8 of the 15 `show*` toggles and 6 of the 22 `color*` + // overrides, so most of the config surface — and every property added to the schema after the + // list was written — was set on the import and then left out of the summary. Reporting the + // build accurately is the whole reason the summary reads from the build result. + if (standardConfig && Object.keys(standardConfig).length > 0) { parts.push(`\n**Standard Style Configuration:**`); - const config = input.standard_config; - // Visibility settings - const visibilitySettings = []; - if (config.showPlaceLabels !== undefined) - visibilitySettings.push( - `Place labels: ${config.showPlaceLabels ? 'shown' : 'hidden'}` - ); - if (config.showRoadLabels !== undefined) - visibilitySettings.push( - `Road labels: ${config.showRoadLabels ? 'shown' : 'hidden'}` - ); - if (config.showPointOfInterestLabels !== undefined) - visibilitySettings.push( - `POI labels: ${config.showPointOfInterestLabels ? 'shown' : 'hidden'}` - ); - if (config.showTransitLabels !== undefined) - visibilitySettings.push( - `Transit labels: ${config.showTransitLabels ? 'shown' : 'hidden'}` - ); - if (config.showPedestrianRoads !== undefined) - visibilitySettings.push( - `Pedestrian roads: ${config.showPedestrianRoads ? 'shown' : 'hidden'}` - ); - if (config.show3dObjects !== undefined) - visibilitySettings.push( - `3D objects: ${config.show3dObjects ? 'shown' : 'hidden'}` - ); - if (config.showAdminBoundaries !== undefined) - visibilitySettings.push( - `Admin boundaries: ${config.showAdminBoundaries ? 'shown' : 'hidden'}` - ); + const visibility: string[] = []; + const colorOverrides: string[] = []; + const other: string[] = []; - if (visibilitySettings.length > 0) { - parts.push(`• Visibility: ${visibilitySettings.join(', ')}`); - } + for (const [key, value] of Object.entries(standardConfig)) { + if (value === undefined) continue; - // Theme settings - if (config.theme) parts.push(`• Theme: ${config.theme}`); - if (config.lightPreset) - parts.push(`• Light preset: ${config.lightPreset}`); + // Its own line, since these are the two that change the whole basemap. + if (key === 'theme') { + parts.push(`• Theme: ${value}`); + continue; + } + if (key === 'lightPreset') { + parts.push(`• Light preset: ${value}`); + continue; + } - // Color overrides - const colorOverrides = []; - if (config.colorMotorways) - colorOverrides.push(`motorways: ${config.colorMotorways}`); - if (config.colorTrunks) - colorOverrides.push(`trunks: ${config.colorTrunks}`); - if (config.colorRoads) colorOverrides.push(`roads: ${config.colorRoads}`); - if (config.colorWater) colorOverrides.push(`water: ${config.colorWater}`); - if (config.colorGreenspace) - colorOverrides.push(`greenspace: ${config.colorGreenspace}`); - if (config.colorAdminBoundaries) - colorOverrides.push(`admin boundaries: ${config.colorAdminBoundaries}`); + if (typeof value === 'boolean') { + // Includes show3dBuildings alongside show3dObjects: hiding buildings and hiding every + // 3D object are different requests, and the former is what `hide` on a building sets. + visibility.push( + `${STANDARD_CONFIG_LABEL[key] ?? key}: ${value ? 'shown' : 'hidden'}` + ); + } else if (key.startsWith('color') && !NON_COLOR_CONFIG.has(key)) { + colorOverrides.push(`${STANDARD_CONFIG_LABEL[key] ?? key}: ${value}`); + } else { + other.push(`${STANDARD_CONFIG_LABEL[key] ?? key}: ${value}`); + } + } + if (visibility.length > 0) { + parts.push(`• Visibility: ${visibility.join(', ')}`); + } if (colorOverrides.length > 0) { parts.push(`• Color overrides: ${colorOverrides.join(', ')}`); } - - // Other settings - if (config.densityPointOfInterestLabels !== undefined) { - parts.push(`• POI density: ${config.densityPointOfInterestLabels}`); + if (other.length > 0) { + parts.push(`• Other: ${other.join(', ')}`); } } @@ -1075,13 +2294,20 @@ ${JSON.stringify(style, null, 2)} config: StyleBuilderToolInput['layers'][0], propertyType: 'color' | 'opacity' | 'width' ): unknown { - // If custom expression is provided, use it - if (config.expression) { + // A caller-supplied expression describes the *colour* — that is what `action: "color"` and + // every documented example use it for. Returned for every property type, it also landed the + // same colour ramp in `fill-opacity` and `line-width`, where the spec expects a number. + if (config.expression && propertyType === 'color') { return config.expression; } - // Generate property-based styling (data-driven) - if (config.property_based && config.property_values) { + // Generate property-based styling (data-driven). `property_values` holds colours, so this + // is a colour expression too — feeding it to opacity or width produced the same mismatch. + if ( + config.property_based && + config.property_values && + propertyType === 'color' + ) { const entries = Object.entries(config.property_values); const expression: unknown[] = ['match', ['get', config.property_based]]; @@ -1097,53 +2323,72 @@ ${JSON.stringify(style, null, 2)} // Generate zoom-based interpolation if (config.zoom_based) { - const minZoom = config.min_zoom ?? 10; - const maxZoom = config.max_zoom ?? 18; + return this.zoomRamp( + value, + propertyType, + config.min_zoom ?? 10, + config.max_zoom ?? 18 + ); + } - if (propertyType === 'width') { - // For width, interpolate from smaller to larger - const minWidth = typeof value === 'number' ? value * 0.5 : 1; - const maxWidth = typeof value === 'number' ? value * 2 : 6; + // Return static value if no expression needed + return value; + } - return [ - 'interpolate', - ['exponential', 1.5], - ['zoom'], - minZoom, - minWidth, - maxZoom, - maxWidth - ]; - } else if (propertyType === 'opacity') { - // For opacity, can fade in/out with zoom - const minOpacity = - typeof value === 'number' ? Math.max(0, value - 0.3) : 0.3; - const maxOpacity = typeof value === 'number' ? value : 1; + /** + * A zoom interpolation for one paint value. + * + * Split out of `generateExpression` so the custom-source path can ask for a zoom ramp without + * also inheriting the colour-expression escape hatches, which do not apply to a number. + */ + private zoomRamp( + value: string | number, + propertyType: 'color' | 'opacity' | 'width', + minZoom: number, + maxZoom: number + ): unknown { + if (propertyType === 'width') { + // For width, interpolate from smaller to larger + const minWidth = typeof value === 'number' ? value * 0.5 : 1; + const maxWidth = typeof value === 'number' ? value * 2 : 6; - return [ - 'interpolate', - ['linear'], - ['zoom'], - minZoom, - minOpacity, - maxZoom, - maxOpacity - ]; - } else if (propertyType === 'color') { - // For color, use step function for discrete changes - const midZoom = (minZoom + maxZoom) / 2; - return [ - 'step', - ['zoom'], - value, // Default color - midZoom, - value // Could be enhanced to transition between colors - ]; - } + return [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], + minZoom, + minWidth, + maxZoom, + maxWidth + ]; } - // Return static value if no expression needed - return value; + if (propertyType === 'opacity') { + // For opacity, can fade in/out with zoom + const minOpacity = + typeof value === 'number' ? Math.max(0, value - 0.3) : 0.3; + const maxOpacity = typeof value === 'number' ? value : 1; + + return [ + 'interpolate', + ['linear'], + ['zoom'], + minZoom, + minOpacity, + maxZoom, + maxOpacity + ]; + } + + // For color, use step function for discrete changes + const midZoom = (minZoom + maxZoom) / 2; + return [ + 'step', + ['zoom'], + value, // Default color + midZoom, + value // Could be enhanced to transition between colors + ]; } /** diff --git a/test/prompts/CreateAndPreviewStylePrompt.test.ts b/test/prompts/CreateAndPreviewStylePrompt.test.ts index 900c72c..c46c142 100644 --- a/test/prompts/CreateAndPreviewStylePrompt.test.ts +++ b/test/prompts/CreateAndPreviewStylePrompt.test.ts @@ -67,6 +67,41 @@ describe('CreateAndPreviewStylePrompt', () => { expect(text).toContain('14'); }); + it('should default to Mapbox Standard rather than a Classic style', () => { + const result = prompt.execute({ + style_name: 'Test Style' + }); + + const text = result.messages[0].content.text; + + expect(text).toContain('mapbox://styles/mapbox/standard'); + expect(text).not.toContain('streets-v12'); + + // Standard arrives as an import, not a hand-authored layer list. + expect(text).toContain('imports'); + expect(text).not.toContain('"background-color": "#f0f0f0"'); + }); + + it('should route dark mode through lightPreset instead of a dark base style', () => { + const result = prompt.execute({ + style_name: 'Test Style' + }); + + const text = result.messages[0].content.text; + expect(text).toContain('lightPreset'); + expect(text).toContain('night'); + }); + + it('should require an explicit slot and emissive strength on custom layers', () => { + const result = prompt.execute({ + style_name: 'Test Style' + }); + + const text = result.messages[0].content.text; + expect(text).toContain('slot'); + expect(text).toContain('emissive strength'); + }); + it('should throw error if required argument is missing', () => { expect(() => { prompt.execute({}); diff --git a/test/prompts/DesignDataDrivenStylePrompt.test.ts b/test/prompts/DesignDataDrivenStylePrompt.test.ts index 6b7893a..634337a 100644 --- a/test/prompts/DesignDataDrivenStylePrompt.test.ts +++ b/test/prompts/DesignDataDrivenStylePrompt.test.ts @@ -138,6 +138,55 @@ describe('DesignDataDrivenStylePrompt', () => { expect(text).toContain('midpoint'); }); + it('should not offer a red-to-green ramp for diverging data', () => { + const result = prompt.execute({ + style_name: 'Test', + data_description: 'Data', + property_name: 'value', + color_scheme: 'diverging' + }); + + const text = result.messages[0].content.text; + + // RdYlGn endpoints. A red-to-green ramp is the most common colorblind failure in + // data visualization, and this prompt used to recommend one while simultaneously + // warning against it further down. + expect(text).not.toContain('#d7191c'); + expect(text).not.toContain('#1a9641'); + expect(text).not.toContain('#a6d96a'); + + // ColorBrewer RdBu instead, which keeps its endpoints distinct under deuteranopia. + expect(text).toContain('#b2182b'); + expect(text).toContain('#2166ac'); + }); + + it('should give every example layer a slot and emissive strength', () => { + for (const visualizationType of ['color', 'size', 'both']) { + const result = prompt.execute({ + style_name: 'Test', + data_description: 'Data', + property_name: 'value', + visualization_type: visualizationType + }); + + const text = result.messages[0].content.text; + expect(text).toContain('"slot"'); + expect(text).toMatch(/-emissive-strength": 1/); + } + }); + + it('should not claim a heatmap emissive-strength property exists', () => { + const result = prompt.execute({ + style_name: 'Test', + data_description: 'Data', + property_name: 'value', + visualization_type: 'heatmap' + }); + + const text = result.messages[0].content.text; + expect(text).not.toContain('"heatmap-emissive-strength": 1'); + }); + it('should include categorical colors when specified', () => { const result = prompt.execute({ style_name: 'Test', diff --git a/test/prompts/PrepareStyleForProductionPrompt.test.ts b/test/prompts/PrepareStyleForProductionPrompt.test.ts index ff5c1d9..4cd43d5 100644 --- a/test/prompts/PrepareStyleForProductionPrompt.test.ts +++ b/test/prompts/PrepareStyleForProductionPrompt.test.ts @@ -80,7 +80,7 @@ describe('PrepareStyleForProductionPrompt', () => { const text = result.messages[0].content.text; expect(text).toContain('optimize_style_tool'); - expect(text).toContain('Step 5: Optimize the Style'); + expect(text).toContain('Step 6: Optimize the Style'); expect(text).not.toContain('Skipped per user request'); }); @@ -91,7 +91,7 @@ describe('PrepareStyleForProductionPrompt', () => { }); const text = result.messages[0].content.text; - expect(text).toContain('Step 5: Style Optimization'); + expect(text).toContain('Step 6: Style Optimization'); expect(text).toContain('Skipped per user request'); expect(text).not.toContain('optimize_style_tool'); }); @@ -166,12 +166,34 @@ describe('PrepareStyleForProductionPrompt', () => { const step2Index = text.indexOf('Step 2: Validate All Expressions'); const step3Index = text.indexOf('Step 3: Validate GeoJSON Sources'); const step4Index = text.indexOf('Step 4: Check Color Contrast'); - const step5Index = text.indexOf('Step 5:'); + const step5Index = text.indexOf( + 'Step 5: Check Standard Style Layer Placement and Lighting' + ); + const step6Index = text.indexOf('Step 6:'); expect(step1Index).toBeGreaterThan(-1); expect(step2Index).toBeGreaterThan(step1Index); expect(step3Index).toBeGreaterThan(step2Index); expect(step4Index).toBeGreaterThan(step3Index); expect(step5Index).toBeGreaterThan(step4Index); + expect(step6Index).toBeGreaterThan(step5Index); + }); + + it('should check slots and emissive strength for Standard styles', () => { + const result = prompt.execute({ + style_id_or_json: 'test/style' + }); + + const text = result.messages[0].content.text; + + // The two failures that valid-but-wrong Standard styles ship with. + expect(text).toContain('slot'); + expect(text).toContain('fill-emissive-strength'); + expect(text).toContain('line-emissive-strength'); + expect(text).toContain('circle-emissive-strength'); + expect(text).toContain('line-occlusion-opacity'); + + // Symbol and fill-extrusion layers must not be flagged for emissive strength. + expect(text).toContain('fill-extrusion'); }); }); diff --git a/test/resources/MapboxStyleLayersResource.test.ts b/test/resources/MapboxStyleLayersResource.test.ts index cf03aeb..2c35b82 100644 --- a/test/resources/MapboxStyleLayersResource.test.ts +++ b/test/resources/MapboxStyleLayersResource.test.ts @@ -21,4 +21,49 @@ describe('MapboxStyleLayersResource', () => { ); }); }); + + describe('Mapbox Standard guidance', () => { + const readMarkdown = async () => { + const result = await resource.readCallback( + new URL('resource://mapbox-style-layers'), + undefined + ); + return result.contents[0].text as string; + }; + + it('should document all three slots and what belongs in each', async () => { + const markdown = await readMarkdown(); + + expect(markdown).toContain('`bottom`'); + expect(markdown).toContain('`middle`'); + expect(markdown).toContain('`top`'); + + // Omitting slot is a defect, not a neutral default — the guide has to say so. + expect(markdown).toContain('street labels'); + }); + + it('should document emissive strength for fill, line and circle', async () => { + const markdown = await readMarkdown(); + + expect(markdown).toContain('fill-emissive-strength'); + expect(markdown).toContain('line-emissive-strength'); + expect(markdown).toContain('circle-emissive-strength'); + + // The reason it matters is the default, so the guide must state it. + expect(markdown).toContain('night'); + }); + + it('should document line-occlusion-opacity for routes', async () => { + const markdown = await readMarkdown(); + expect(markdown).toContain('line-occlusion-opacity'); + }); + + it('should not send callers chasing emissive strength on symbol layers', async () => { + const markdown = await readMarkdown(); + + // icon- and text-emissive-strength already default to 1, so symbol layers are + // safe as-is. This was the factual error worth being explicit about. + expect(markdown).toContain('Symbol layers need nothing'); + }); + }); }); diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index a7277a3..f32ed62 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -29,7 +29,17 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot }, { "className": "CreateStyleTool", - "description": "Create a new Mapbox style", + "description": "Create a new Mapbox style from a complete style JSON. + +Uploads whatever JSON it is given — it does not build a style. Prefer style_builder_tool for that: +it defaults to Mapbox Standard and supplies what hand-authored styles miss, an explicit slot on +every custom layer and emissive strength so they survive the dusk/night presets. + +First check whether Standard's config already expresses the intent — theme, lightPreset, show* and +color* need no style to create or maintain. A dark map is lightPreset:'night', not a new style. + +A Standard style is an 'imports' entry, not a layer stack: no background layer, no basemap layers +copied in, and 'sources'/'layers' hold only your own data. Omitting imports makes a Classic style.", "toolName": "create_style_tool", }, { @@ -84,67 +94,42 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot }, { "className": "StyleBuilderTool", - "description": "Generate Mapbox style JSON for creating new styles or updating existing ones. - -The tool intelligently resolves layer types and filter properties using Streets v8 data. -You don't need exact layer names - the tool automatically finds the correct layer based on your filters. - -BASE STYLES: -• standard: ALWAYS THE DEFAULT - Modern Mapbox Standard with best performance -• Classic styles: streets-v12/light-v11/dark-v11/satellite-v9/outdoors-v12/satellite-streets-v12/navigation-day-v1/navigation-night-v1 - Only use Classic when user explicitly says "create a classic style" or working with existing Classic style - -STANDARD STYLE CONFIG: -Use standard_config to customize the basemap: -• Theme: default/faded/monochrome -• Light: day/night/dawn/dusk -• Show/hide: labels, roads, 3D buildings -• Colors: water, roads, parks, etc. - -LAYER ORDERING: -• Layers are rendered in order - last layer in the array appears visually on top -• The 'slot' parameter is OPTIONAL - by default, layer order in the array determines visibility -• For Standard style, you can optionally use 'slot' to control placement: - - No slot (default): Above all existing layers in the style - - 'top': Behind Place and Transit labels - - 'middle': Between basemap and labels - - 'bottom': Below most basemap features - -LAYER RENDERING: -• render_type controls HOW to visualize the layer (line, fill, symbol, etc.) -• Most important: Use render_type:"line" for outlines/borders even on polygon features -• Default "auto" picks based on geometry, but override for specific effects: - - Building outlines → render_type:"line" (not fill!) - - Solid buildings → render_type:"fill" or "fill-extrusion" (3D) - - Road lines → render_type:"line" (auto works too) - - POI dots → render_type:"circle" - - Labels → render_type:"symbol" - -LAYER ACTIONS: -• color: Apply a specific color -• highlight: Make prominent -• hide: Remove from view -• show: Display with defaults + "description": "Generate Mapbox style JSON. Resolves layer types and filters against Streets v8, so +approximate layer names work — { class: 'park' } finds 'landuse', { maki: 'cafe' } finds 'poi_label'. +Use "admin" for all boundaries, "building" (singular), "road" for all streets. Unrecognized types +come back with the available layers and fields. See resource://mapbox-style-layers for slots, +emissive strength, layer properties and worked examples. -AUTO-DETECTION: -The tool automatically finds the correct layer from your filter_properties. -Examples: -• { class: 'park' } → finds 'landuse' layer -• { type: 'wetland' } → finds 'landuse_overlay' layer -• { maki: 'restaurant' } → finds 'poi_label' layer -• { toll: true } → finds 'road' layer -• { admin_level: 0 } → finds 'admin' layer (for country boundaries) -• { admin_level: 1 } → finds 'admin' layer (for state/province boundaries) +TARGET — base_style decides which options apply, and the wrong ones are REJECTED, not ignored: +• 'standard' is the default and almost always right. Takes standard_config; slots; lit scene +• Classic (streets-v12/light-v11/dark-v11/satellite-*/outdoors-v12/navigation-*) only when a + classic style is explicitly asked for. Takes global_settings; no config surface, no slots +• A Classic base is NOT an import and does not reproduce the named style — this tool authors the + stack, so EVERYTHING you want drawn must be listed in 'layers', and an empty list is REJECTED. + To use the named style itself, reference mapbox://styles/mapbox/ in the map rather than + building anything here. The base name only sets light vs dark and whether satellite imagery goes + underneath; bases within a group are equivalent -IMPORTANT LAYER NAMES: -• Use "admin" for all boundaries (countries, states, etc.) -• Use "building" (singular, not "buildings") -• Use "road" for all streets, highways, paths +ON STANDARD, CONFIGURE BEFORE YOU LAYER: +The basemap belongs to the import and this tool cannot reach into it, so a Streets v8 layer draws a +SECOND copy over the basemap's own. standard_config restyles the basemap itself: +• theme 'faded'/'monochrome' — fastest way to make your data pop +• lightPreset 'night' — THIS is dark mode. Not dark-v11, not global_settings.mode +• show* toggles to hide what competes with your data; color* to retint water/roads/parks/labels +• action:'hide' on Standard sets the matching toggle (poi_label→showPointOfInterestLabels, + place_label, transit_stop_label, building→show3dBuildings, admin→showAdminBoundaries). Water, + landuse and the road network have no toggle, so 'hide' on those is rejected — make them recede + with theme and color* instead. On Classic, 'hide' just omits the layer -If a layer type is not recognized, the tool will provide helpful suggestions showing: -• All available source layers from Streets v8 -• Which fields are available in each layer -• Examples of how to properly specify layers and filters", +YOUR OWN DATA — custom_sources (the layer_type lookup covers Streets v8 only, not your data): +• custom_sources: { zones: { type: 'geojson', data: } }, then + layer: { layer_type: 'Zones', source_id: 'zones', render_type: 'fill', color: '#7b61ff' } +• render_type is REQUIRED here; for type 'vector' also set source_layer. 'composite' (and + 'satellite' on a satellite base) are reserved and rejected +• Color by value as on any layer: 'expression' for a ramp, or property_based + property_values + for a category match ('color' becomes the fallback arm) +• These get overlay placement, emissive strength and line-occlusion-opacity set for you. Override + with slot:'bottom' on a choropleth, so the road network reads over it", "toolName": "style_builder_tool", }, { diff --git a/test/tools/create-style-tool/CreateStyleTool.test.ts b/test/tools/create-style-tool/CreateStyleTool.test.ts index 1304440..ae64df9 100644 --- a/test/tools/create-style-tool/CreateStyleTool.test.ts +++ b/test/tools/create-style-tool/CreateStyleTool.test.ts @@ -25,7 +25,14 @@ describe('CreateStyleTool', () => { const { httpRequest } = setupHttpRequest(); const tool = new CreateStyleTool({ httpRequest }); expect(tool.name).toBe('create_style_tool'); - expect(tool.description).toBe('Create a new Mapbox style'); + expect(tool.description).toContain('Create a new Mapbox style'); + + // The description has to be clear that this tool uploads a style rather than builds + // one, and point at the tool that does build one correctly. Without that, agents + // hand-author a version/sources/layers object and lose imports, config and slots. + expect(tool.description).toContain('style_builder_tool'); + expect(tool.description).toContain('imports'); + expect(tool.description).toContain('lightPreset'); }); it('should have correct input schema', async () => { diff --git a/test/tools/style-builder-tool/StyleBuilderTool.test.ts b/test/tools/style-builder-tool/StyleBuilderTool.test.ts index 97ce8ca..52affbd 100644 --- a/test/tools/style-builder-tool/StyleBuilderTool.test.ts +++ b/test/tools/style-builder-tool/StyleBuilderTool.test.ts @@ -3,7 +3,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { StyleBuilderTool } from '../../../src/tools/style-builder-tool/StyleBuilderTool.js'; -import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.input.schema.js'; +import { + StyleBuilderToolSchema, + type StyleBuilderToolInput +} from '../../../src/tools/style-builder-tool/StyleBuilderTool.input.schema.js'; describe('StyleBuilderTool', () => { let tool: StyleBuilderTool; @@ -47,7 +50,9 @@ describe('StyleBuilderTool', () => { const input: StyleBuilderToolInput = { style_name: 'Dark Mode Style', base_style: 'streets-v12', // Use classic style to test background color - layers: [], + // A Classic build needs at least one layer — an empty stack is a redirect now — and this + // test is about global_settings reaching the output, not about the empty case. + layers: [{ layer_type: 'water', action: 'color', color: '#0a1a2a' }], global_settings: { mode: 'dark', background_color: '#000000' @@ -281,25 +286,20 @@ describe('StyleBuilderTool', () => { } }); - it('should include only background layer when no layers specified', async () => { - // Test with classic style - const input: StyleBuilderToolInput = { + it('should build no style at all when a Classic base is given no layers', async () => { + // This used to assert the opposite — a lone background layer — which is the outcome the + // redirect replaced: a style reported as built that draws nothing but a colour. See + // "should redirect an empty Classic build instead of shipping a bare background" for the + // guidance itself. + const result = await tool.run({ style_name: 'Essential Layers Test', - base_style: 'streets-v12', // Use classic style - layers: [] // No layers specified - }; - - const result = await tool.run(input); + base_style: 'streets-v12', + layers: [] + } as StyleBuilderToolInput); const text = result.content[0].text as string; - const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); - const style = JSON.parse(jsonMatch![1]); - - // Classic styles should only have background when no layers specified - expect(style.layers.length).toBe(1); - - const bgLayer = style.layers.find((l: any) => l.id === 'background'); - expect(bgLayer).toBeTruthy(); + expect(text).not.toContain('Style Built Successfully'); + expect(text.match(/```json\n([\s\S]*?)\n```/)).toBeNull(); }); }); @@ -757,11 +757,15 @@ describe('StyleBuilderTool', () => { expect(style.sources).toBeDefined(); expect(style.sources.composite).toBeDefined(); - // Check that layers don't have slot by default for Standard style - // No slot means the layer appears above all existing layers + // Every custom layer on a Standard style gets a slot. Leaving it off would put the + // layer above every basemap layer, including street labels. style.layers.forEach((layer: any) => { - expect(layer.slot).toBeUndefined(); + expect(layer.slot).toBeDefined(); + expect(['bottom', 'middle', 'top']).toContain(layer.slot); }); + + // The inferred slot is reported rather than applied silently. + expect(text).toContain('inferred slot'); }); it('should generate Standard style with configuration', async () => { @@ -923,6 +927,1612 @@ describe('StyleBuilderTool', () => { expect(poiLayer.slot).toBe('top'); }); + it('should give Standard styles a sprite that can resolve the maki icons it references', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Sprite Test', + base_style: 'standard', + layers: [ + { + layer_type: 'poi_label', + action: 'show', + render_type: 'symbol' + } + ] + }; + + const result = await tool.run(input); + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + const symbolLayer = style.layers.find((l: any) => l.type === 'symbol'); + + // The layer references an icon by maki name — either ["get", "maki"] off the + // Streets v8 field or a literal default like "marker-15". + expect(symbolLayer.layout['icon-image']).toBeDefined(); + + // So the root sprite has to be one that contains maki icons. Standard's own icons + // live in the import's scope and are not addressable from these layers, which is + // why the Streets sprite is correct here despite the Standard basemap. + expect(style.sprite).toBe('mapbox://sprites/mapbox/streets-v12'); + }); + + it('should build layers over the callers own GeoJSON sources', async () => { + const result = await tool.run({ + style_name: 'Delivery', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/zones.geojson' }, + route: { type: 'geojson', data: 'https://example.com/route.geojson' } + }, + layers: [ + { + layer_type: 'Delivery zones', + source_id: 'zones', + action: 'color', + color: '#7b61ff', + opacity: 0.6, + render_type: 'fill' + }, + { + layer_type: 'Route', + source_id: 'route', + action: 'color', + color: '#3b6df5', + width: 4, + render_type: 'line' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + // The sources land alongside the basemap source under the ids the layers use. + expect(style.sources.zones).toEqual({ + type: 'geojson', + data: 'https://example.com/zones.geojson' + }); + expect(style.sources.route).toBeDefined(); + + const fill = style.layers.find((l: any) => l.type === 'fill'); + const line = style.layers.find((l: any) => l.type === 'line'); + + // The user's own data is an overlay, so it belongs above roads and behind labels — + // unlike a basemap-derived fill, which goes in `bottom`, under the road network. + expect(fill.slot).toBe('middle'); + expect(line.slot).toBe('middle'); + + expect(fill.paint['fill-emissive-strength']).toBe(1); + expect(line.paint['line-emissive-strength']).toBe(1); + + // A route hidden behind 3D buildings is the failure this prevents. + expect(line.paint['line-occlusion-opacity']).toBe(1); + + // The inferred slot is reported here too, not just on basemap layers — and a fill gets + // told about `bottom`, the one case the overlay default is usually wrong for. + expect(text).toContain('inferred slot "middle"'); + expect(text).toContain('slot: "bottom"'); + }); + + it('should expose every documented Mapbox Standard config property', () => { + // Every property documented at docs.mapbox.com/map-styles/reference/standard/ as of + // 2026-07. The schema strips unknown keys, so a property missing here is one a caller can + // set and never be told was dropped — the same silence the target check exists to end. + const documented = [ + 'showPedestrianRoads', + 'showPlaceLabels', + 'showPointOfInterestLabels', + 'showRoadLabels', + 'showTransitLabels', + 'show3dObjects', + 'show3dBuildings', + 'show3dTrees', + 'show3dLandmarks', + 'show3dFacades', + 'showLandmarkIcons', + 'showLandmarkIconLabels', + 'showAdminBoundaries', + 'showIndoor', + 'showIndoorLabels', + 'theme', + 'theme-data', + 'lightPreset', + 'font', + 'colorModePointOfInterestLabels', + 'backgroundPointOfInterestLabels', + 'densityPointOfInterestLabels', + 'fuelingStationModePointOfInterestLabels', + 'colorPlaceLabels', + 'colorRoadLabels', + 'colorPointOfInterestLabels', + 'colorCommercial', + 'colorEducation', + 'colorMedical', + 'colorIndustrial', + 'colorGreenspace', + 'colorWater', + 'colorLand', + 'colorAdminBoundaries', + 'colorMotorways', + 'colorTrunks', + 'colorRoads', + 'colorBuildings', + 'colorBuildingHighlight', + 'colorBuildingSelect', + 'colorPlaceLabelHighlight', + 'colorPlaceLabelSelect', + 'colorIndoorLabelSelect', + 'colorIndoorLabelHighlight' + ]; + + const shape = ( + StyleBuilderToolSchema.shape.standard_config as any + ).unwrap().shape; + const keys = Object.keys(shape); + + expect(documented.filter((p) => !keys.includes(p))).toEqual([]); + // showRoadsAndTransit is the one deliberate extra: a Standard *Satellite* property, kept + // in the schema so passing it is an error rather than a silently dropped key. + expect(keys.filter((k) => !documented.includes(k))).toEqual([ + 'showRoadsAndTransit' + ]); + }); + + it('should report every config property it set, not a hand-picked subset', async () => { + const result = await tool.run({ + style_name: 'Full config', + base_style: 'standard', + layers: [], + standard_config: { + // None of these five appeared in the summary before: the report walked a written-out + // list covering 8 of the 15 show* toggles and 6 of the 22 color* overrides. + showIndoorLabels: false, + show3dTrees: false, + colorIndustrial: '#b0a08f', + colorPlaceLabels: '#222222', + fuelingStationModePointOfInterestLabels: 'default' + } + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + + expect(text).toContain('Indoor labels: hidden'); + expect(text).toContain('3D trees: hidden'); + expect(text).toContain('industrial: #b0a08f'); + expect(text).toContain('place labels: #222222'); + expect(text).toContain('Fueling station POI mode: default'); + }); + + it('should convert an object filter on your own data into an expression', async () => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Active zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#7b61ff', + filter: { status: ['active', 'pending'], tier: 2 } + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const fill = style.layers.find((l: any) => l.type === 'fill'); + + // The object shape carries over from a basemap layer, where field metadata resolves it. + // Custom data has none, so this used to be assigned raw — a style the spec rejects with + // "array expected, object found", surfacing only once create_style_tool uploaded it. + expect(fill.filter).toEqual([ + 'all', + ['match', ['get', 'status'], ['active', 'pending'], true, false], + ['==', ['get', 'tier'], 2] + ]); + expect(text).toContain('converted to'); + }); + + it('should reject a filter with nothing to test against', async () => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#7b61ff', + filter: 'active' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('must be an expression or a property object'); + expect(text).not.toContain('Style Built Successfully'); + }); + + it('should keep an expression filter on your own data untouched', async () => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Big zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#7b61ff', + filter: ['>', ['get', 'area'], 500] + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const fill = style.layers.find((l: any) => l.type === 'fill'); + + expect(fill.filter).toEqual(['>', ['get', 'area'], 500]); + }); + + it('should honour colour and opacity on a heatmap of your own data', async () => { + const result = await tool.run({ + style_name: 'Incidents', + base_style: 'standard', + custom_sources: { + pts: { type: 'geojson', data: 'https://example.com/p.geojson' } + }, + layers: [ + { + layer_type: 'Incident density', + source_id: 'pts', + render_type: 'heatmap', + action: 'color', + color: '#ff0000', + opacity: 0.5 + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const heatmap = style.layers.find((l: any) => l.type === 'heatmap'); + + // heatmap-color takes a ramp over heatmap-density rather than a colour, and heatmap-opacity + // was missing from the opacity table — so both of these used to be dropped in silence, + // leaving a layer with no paint at all reported as built successfully. + expect(heatmap.paint['heatmap-color']).toEqual([ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(0, 0, 0, 0)', + 1, + '#ff0000' + ]); + expect(heatmap.paint['heatmap-opacity']).toBe(0.5); + expect(text).toContain('ramps from transparent to #ff0000'); + }); + + it('should pass a caller ramp straight through to heatmap-color', async () => { + const ramp = [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(0, 0, 0, 0)', + 1, + '#08519c' + ]; + const result = await tool.run({ + style_name: 'Incidents', + base_style: 'standard', + custom_sources: { + pts: { type: 'geojson', data: 'https://example.com/p.geojson' } + }, + layers: [ + { + layer_type: 'Incident density', + source_id: 'pts', + render_type: 'heatmap', + action: 'color', + property_based: 'weight', + property_values: { high: 1 }, + expression: ramp + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const heatmap = style.layers.find((l: any) => l.type === 'heatmap'); + + expect(heatmap.paint['heatmap-color']).toEqual(ramp); + // Density is computed from the points, so a per-feature match cannot colour a heatmap. + // Pointed at heatmap-weight instead of being accepted and ignored. + expect(text).toContain('does not colour a heatmap'); + expect(text).toContain('heatmap-weight'); + }); + + it('should report width it cannot apply instead of dropping it', async () => { + const result = await tool.run({ + style_name: 'Stores', + base_style: 'standard', + custom_sources: { + pts: { type: 'geojson', data: 'https://example.com/p.geojson' } + }, + layers: [ + { + layer_type: 'Stores', + source_id: 'pts', + render_type: 'circle', + action: 'color', + color: '#7b61ff', + width: 8 + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('`width` was ignored'); + expect(text).toContain('circle-radius'); + }); + + it('should colour your own data by value rather than dropping the expression', async () => { + const result = await tool.run({ + style_name: 'Anomaly', + base_style: 'standard', + custom_sources: { + counties: { type: 'geojson', data: 'https://example.com/c.geojson' } + }, + layers: [ + { + layer_type: 'Counties by anomaly', + source_id: 'counties', + render_type: 'fill', + action: 'color', + slot: 'bottom', + expression: [ + 'interpolate', + ['linear'], + ['get', 'anomaly'], + -5, + '#b2182b', + 0, + '#f7f7f7', + 5, + '#2166ac' + ] + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const fill = style.layers.find((l: any) => l.type === 'fill'); + + // Dropped, this left a fill with no fill-color at all — which the spec draws as opaque + // black over the whole map, for the single most common thing you'd bring your own data for. + expect(fill.paint['fill-color'][0]).toBe('interpolate'); + expect(fill.paint['fill-color']).toContain('#2166ac'); + }); + + it('should build a category match for your own data, always with a fallback arm', async () => { + const result = await tool.run({ + style_name: 'Tiers', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Zones by tier', + source_id: 'zones', + render_type: 'fill', + action: 'color', + slot: 'middle', + property_based: 'tier', + property_values: { high: '#e41a1c', low: '#377eb8' } + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const fillColor = style.layers.find((l: any) => l.type === 'fill').paint[ + 'fill-color' + ]; + + expect(fillColor[0]).toBe('match'); + expect(fillColor[1]).toEqual(['get', 'tier']); + // A match with no fallback draws nothing for an unlisted value, so there is always one. + expect(fillColor[fillColor.length - 1]).toBe('#999999'); + // And the caller is told which colour it got, rather than finding out from the JSON. + expect(text).toContain('fallback'); + }); + + it('should not put a colour expression into a numeric paint property', async () => { + const result = await tool.run({ + style_name: 'Heights', + base_style: 'standard', + layers: [ + { + layer_type: 'building', + action: 'color', + color: '#808080', + opacity: 0.5, + render_type: 'fill', + slot: 'bottom', + expression: [ + 'case', + ['>', ['get', 'height'], 100], + '#ff0000', + '#808080' + ] + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const paint = style.layers.find((l: any) => l.type === 'fill').paint; + + // The expression describes the colour. Returned for every property type, it also landed + // the same colour ramp in fill-opacity, where the spec expects a number. + expect(paint['fill-color'][0]).toBe('case'); + expect(paint['fill-opacity']).toBe(0.5); + }); + + it('should give every layer a distinct id, including two off one custom source', async () => { + const result = await tool.run({ + style_name: 'Split zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'High tier', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#e41a1c', + slot: 'middle', + filter: ['==', ['get', 'tier'], 'high'] + }, + { + layer_type: 'Low tier', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#377eb8', + slot: 'middle', + filter: ['==', ['get', 'tier'], 'low'] + }, + // Same basemap feature twice with no filter derives the same name too. + { + layer_type: 'water', + action: 'color', + color: '#0af', + slot: 'bottom' + }, + { + layer_type: 'water', + action: 'color', + color: '#08f', + slot: 'bottom' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const ids = style.layers.map((l: any) => l.id); + + // Duplicate ids are invalid per the style spec, and the collision was silent. + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toContain('zones-fill'); + expect(ids).toContain('zones-fill-2'); + }); + + it('should give a symbol layer over your own data something to draw', async () => { + const result = await tool.run({ + style_name: 'Stores', + base_style: 'standard', + custom_sources: { + stores: { type: 'geojson', data: 'https://example.com/s.geojson' } + }, + layers: [ + { + layer_type: 'Stores', + source_id: 'stores', + render_type: 'symbol', + action: 'show', + slot: 'top' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const symbol = style.layers.find((l: any) => l.type === 'symbol'); + + // With neither text-field nor icon-image a symbol layer renders nothing at all, so this + // was a layer that was present, valid and invisible. + expect(symbol.layout['text-field']).toEqual(['get', 'name']); + // No icon-image: a literal would resolve against the Streets sprite and put the same + // generic pin on every feature. + expect(symbol.layout['icon-image']).toBeUndefined(); + // The assumed property is named, since the tool cannot see the data to check it. + expect(text).toContain('`name` property'); + }); + + it('should not leave a layer over your own data with no colour at all', async () => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'show' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + // fill-color defaults to opaque black, so omitting it is not "unstyled" — it is a black + // slab over the whole map. Same failure as a `match` with no fallback arm. + expect(style.layers[0].paint['fill-color']).toBe('#999999'); + expect(text).toContain('opaque black'); + }); + + it('should normalise a bare hex colour on your own data, as it does on the basemap', async () => { + const build = async (layer: Record) => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [layer] + } as StyleBuilderToolInput); + return JSON.parse( + (result.content[0].text as string).match( + /```json\n([\s\S]*?)\n```/ + )![1] + ); + }; + + // "7b61ff" is not a colour the spec can parse, so it failed validation at upload rather + // than here. The basemap path has always prefixed it; this one passed it straight through. + const bare = await build({ + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '7b61ff' + }); + expect(bare.layers[0].paint['fill-color']).toBe('#7b61ff'); + + // A functional or named colour is already valid and must not be prefixed into "#red". + const named = await build({ + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: 'rgba(123, 97, 255, 0.5)' + }); + expect(named.layers[0].paint['fill-color']).toBe( + 'rgba(123, 97, 255, 0.5)' + ); + }); + + it('should give a fill-extrusion over your own data a height and no slot', async () => { + const result = await tool.run({ + style_name: 'Buildings', + base_style: 'standard', + custom_sources: { bld: { type: 'vector', url: 'mapbox://me.bld' } }, + layers: [ + { + layer_type: 'My buildings', + source_id: 'bld', + source_layer: 'bld', + render_type: 'fill-extrusion', + action: 'color', + color: '#cccccc' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + const layer = style.layers[0]; + + // fill-extrusion-height defaults to 0, so without one the layer is present, valid and flat. + expect(layer.paint['fill-extrusion-height']).toEqual(['get', 'height']); + expect(text).toContain('`height` property'); + + // Unslotted for the same reason a basemap fill-extrusion is: it is real 3D geometry that + // depth-sorts against the buildings around it, and a slot flattens it into the 2D stack. + // Whose data it is doesn't change that. + expect(layer.slot).toBeUndefined(); + expect(text).toContain('without a slot deliberately'); + }); + + it('should say when zoom_based had nothing to ramp on your own data', async () => { + const result = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#7b61ff', + zoom_based: true + } + ] + } as StyleBuilderToolInput); + + // zoom_based ramps opacity and width; with neither set it was accepted and did nothing. + expect(result.content[0].text as string).toContain( + '`zoom_based` had no effect' + ); + + // With something to act on it ramps, and says nothing. + const ramped = await tool.run({ + style_name: 'Zones', + base_style: 'standard', + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Zones', + source_id: 'zones', + render_type: 'fill', + action: 'color', + color: '#7b61ff', + opacity: 0.6, + zoom_based: true + } + ] + } as StyleBuilderToolInput); + const rampedText = ramped.content[0].text as string; + const style = JSON.parse( + rampedText.match(/```json\n([\s\S]*?)\n```/)![1] + ); + expect(style.layers[0].paint['fill-opacity'][0]).toBe('interpolate'); + expect(rampedText).not.toContain('`zoom_based` had no effect'); + }); + + it('should say a fill-extrusion is unslotted on purpose, not report a slot of undefined', async () => { + const result = await tool.run({ + style_name: '3D', + base_style: 'standard', + layers: [ + { + layer_type: 'building', + render_type: 'fill-extrusion', + action: 'color', + color: '#cccccc' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + // Real 3D geometry is left unslotted deliberately, so the message must not read as a + // failed inference — nor claim the layer will draw over the street labels. + expect(style.layers[0].slot).toBeUndefined(); + expect(text).not.toContain('"undefined"'); + expect(text).toContain('left without a slot deliberately'); + }); + + it('should describe a custom-source layer by the name you gave it', async () => { + const result = await tool.run({ + style_name: 'Hydrology', + base_style: 'standard', + custom_sources: { + water: { type: 'geojson', data: 'https://example.com/w.geojson' } + }, + layers: [ + { + layer_type: 'water', + source_id: 'water', + render_type: 'fill', + action: 'color', + color: '#123456', + slot: 'bottom' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + + // layer_type is free text on a custom-source layer, so looking it up reported the + // caller's GeoJSON as Streets v8 metadata it has nothing to do with. + expect(text).toContain('• water: Set to #123456'); + expect(text).not.toContain('water layer (Polygon geometry)'); + }); + + it('should require render_type and a known source_id for user data layers', async () => { + const missingRenderType = await tool.run({ + style_name: 'G', + base_style: 'standard', + custom_sources: { z: { type: 'geojson', data: 'u' } }, + layers: [{ layer_type: 'z', source_id: 'z', action: 'color' }] + } as StyleBuilderToolInput); + // Geometry cannot be inferred from a URL, so "auto" has nothing to work from. + expect(missingRenderType.content[0].text).toContain( + 'render_type is required' + ); + + const unknownSource = await tool.run({ + style_name: 'G', + base_style: 'standard', + layers: [ + { + layer_type: 'z', + source_id: 'nope', + action: 'color', + render_type: 'fill' + } + ] + } as StyleBuilderToolInput); + expect(unknownSource.content[0].text).toContain('Unknown source_id'); + }); + + it('should reject options belonging to the other target instead of ignoring them', async () => { + // Standard relights and recolors labels through its config surface, so these + // Classic-only controls would silently do nothing. + const onStandard = await tool.run({ + style_name: 'S', + base_style: 'standard', + global_settings: { label_color: '#ff00ff' }, + layers: [{ layer_type: 'place_label', action: 'show' }] + } as StyleBuilderToolInput); + expect(onStandard.content[0].text).toContain('do not apply'); + expect(onStandard.content[0].text).toContain('colorPlaceLabels'); + + // And the reverse: Classic has no config surface to configure. + const onClassic = await tool.run({ + style_name: 'C', + base_style: 'dark-v11', + standard_config: { lightPreset: 'night' }, + layers: [] + } as StyleBuilderToolInput); + expect(onClassic.content[0].text).toContain('do not apply'); + expect(onClassic.content[0].text).toContain('standard_config'); + }); + + it('should reject a slot on Classic rather than dropping it silently', async () => { + // `slot` sits on the layer rather than at the top level, so it was the one wrong-target + // option the descriptor's field walk could not see — and a caller carrying a Standard + // example over to a Classic base sets it every time. + const result = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'water', + action: 'color', + color: '#0099ff', + slot: 'bottom' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('do not apply to a Classic style'); + expect(text).toContain('`slot`'); + expect(text).toContain('layers[0] "water"'); + // Nothing generated, so the caller cannot mistake it for a working style. + expect(text).not.toContain('Style Built Successfully'); + }); + + it('should hide basemap features on Standard through the config toggle', async () => { + // Omitting the layer hides nothing on Standard — the import keeps drawing the feature — + // yet the summary used to report it hidden. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { layer_type: 'building', action: 'hide' } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + expect(style.imports[0].config).toEqual({ + showPointOfInterestLabels: false, + show3dBuildings: false + }); + // And the report names the toggle rather than claiming a hidden layer. + expect(text).toContain('showPointOfInterestLabels'); + expect(text).toContain('Hidden via'); + }); + + it('should reject hiding a Standard feature that has no config toggle', async () => { + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'water', action: 'hide' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('cannot be hidden on a Standard style'); + expect(text).toContain('colorWater'); + expect(text).not.toContain('Style Built Successfully'); + + // The road network is the same case, and worth its own pointer: the toggles that exist + // cover the labels and the pedestrian paths, not the carriageways. + const roads = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'road', action: 'hide' }] + } as StyleBuilderToolInput); + expect(roads.content[0].text).toContain('showRoadLabels'); + expect(roads.content[0].text).toContain('showPedestrianRoads'); + }); + + it('should resolve the layer name before deciding whether it can be hidden', async () => { + // The hide answer has to come from the *resolved* source layer, as every other action + // does. Keyed off the raw layer_type instead, "pois" was rejected as unhideable by a + // message that went on to list poi_label as hideable. + const hidden = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { + layer_type: 'pois', + filter_properties: { maki: 'restaurant' }, + action: 'hide' + } + ] + } as StyleBuilderToolInput); + + const text = hidden.content[0].text as string; + expect(text).toContain('Style Built Successfully'); + expect(text).not.toContain('cannot be hidden'); + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + expect(style.imports[0].config).toEqual({ + showPointOfInterestLabels: false + }); + // And the summary names the toggle it resolved to, not the name that was passed in. + expect(text).toContain( + 'Hidden via `standard_config.showPointOfInterestLabels`' + ); + }); + + it('should hide your own data layer by omission, on either target', async () => { + // A custom_sources layer is not the import's to remove, so `hide` is omission on + // Standard too — the show* toggles are advice about the basemap, not about your data. + for (const baseStyle of ['standard', 'streets-v12']) { + const result = await tool.run({ + style_name: 'S', + base_style: baseStyle, + custom_sources: { + zones: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'Delivery zones', + source_id: 'zones', + render_type: 'fill', + action: 'hide' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('Style Built Successfully'); + expect(text).not.toContain('cannot be hidden'); + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + expect(style.layers.some((l: any) => l.source === 'zones')).toBe(false); + // No config toggle was invented for it, so it reports as a plain omission. + expect(text).toContain('Hidden'); + expect(text).not.toContain('Hidden via'); + } + }); + + it('should report an unknown layer type as unknown even when hiding it', async () => { + // The suggestion list is the useful answer; a verdict about what Standard can hide + // diagnoses the wrong problem. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'labels', action: 'hide' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).not.toContain('cannot be hidden on a Standard style'); + // Same guidance the identical input gets under any other action. + const shown = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'labels', action: 'show' }] + } as StyleBuilderToolInput); + expect(text).toBe(shown.content[0].text as string); + }); + + it('should hide buildings without taking the 3D trees and landmarks too', async () => { + // show3dObjects is the whole 3D group. Hiding a building through it also strips the + // trees and landmarks nobody mentioned, while the report claimed only buildings. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'building', action: 'hide' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + expect(style.imports[0].config).toEqual({ show3dBuildings: false }); + expect(style.imports[0].config.show3dObjects).toBeUndefined(); + + // The blunter toggle is still reachable for a caller who does want all 3D gone. + const allOff = await tool.run({ + style_name: 'S', + base_style: 'standard', + standard_config: { show3dObjects: false }, + layers: [] + } as StyleBuilderToolInput); + const allOffStyle = JSON.parse( + (allOff.content[0].text as string).match(/```json\n([\s\S]*?)\n```/)![1] + ); + expect(allOffStyle.imports[0].config).toEqual({ show3dObjects: false }); + }); + + it('should reject a config property belonging to Standard Satellite', async () => { + // showRoadsAndTransit exists on Standard Satellite, not on the plain Standard this tool + // imports — so it used to land an inert key in the import config. The wrong-target check + // only compared Standard against Classic, never against a different Standard. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + standard_config: { showRoadsAndTransit: false }, + layers: [{ layer_type: 'water', action: 'show' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('showRoadsAndTransit'); + expect(text).toContain('Standard Satellite only'); + expect(text).not.toContain('Style Built Successfully'); + // And it points at what does work on Standard. + expect(text).toContain('showRoadLabels'); + }); + + it('should describe a filter-resolved layer by the name the style uses', async () => { + // The summary used to re-derive the description from the raw input, so a layer passed as + // "pois" was reported under a name appearing nowhere in the built style. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { + layer_type: 'pois', + filter_properties: { maki: 'restaurant' }, + action: 'color', + color: '#ff0000' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + const summary = text.slice(text.indexOf('**Layer Configurations:**')); + expect(summary).not.toContain('pois'); + expect(summary).toContain('poi_label'); + }); + + it('should report one correction when two layers hide the same feature', async () => { + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { + layer_type: 'poi_label', + filter_properties: { class: 'food_and_drink' }, + action: 'hide' + } + ] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + // One decision, one line — repeating it reads like two things happened. + const hits = text.match( + /set `standard_config\.showPointOfInterestLabels/g + ); + expect(hits).toHaveLength(1); + }); + + it('should name a hide that is redrawn by an unfiltered custom layer', async () => { + // Hiding the basemap POIs and drawing a filtered subset is a real technique, so it is + // named rather than rejected — but an unfiltered redraw just reinstates what was hidden. + const unfiltered = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { layer_type: 'poi_label', action: 'color', color: '#ff0000' } + ] + } as StyleBuilderToolInput); + expect(unfiltered.content[0].text).toContain( + 'redraws what the toggle just hid' + ); + + const filtered = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { + layer_type: 'poi_label', + filter_properties: { class: 'food_and_drink' }, + action: 'color', + color: '#ff0000' + } + ] + } as StyleBuilderToolInput); + const filteredText = filtered.content[0].text as string; + expect(filteredText).toContain('filtered subset'); + expect(filteredText).not.toContain('redraws what the toggle just hid'); + expect(filteredText).toContain('Style Built Successfully'); + + // Keyed by the hidden feature, not by the layer that hid it: two layers hiding one + // feature is still one contradiction, and reporting it twice is the noise this avoids. + const twoHides = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { + layer_type: 'pois', + filter_properties: { maki: 'cafe' }, + action: 'hide' + }, + { layer_type: 'poi_label', action: 'color', color: '#ff0000' } + ] + } as StyleBuilderToolInput); + const hits = (twoHides.content[0].text as string).match( + /is hidden through `standard_config/g + ); + expect(hits).toHaveLength(1); + }); + + it('should flag a hide that contradicts an explicit show toggle', async () => { + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + standard_config: { showPlaceLabels: true }, + layers: [{ layer_type: 'place_label', action: 'hide' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + // The hide wins, but silently resolving the contradiction is how a caller ends up + // believing the toggle they set is what shipped. + expect(text).toContain('The hide won'); + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + expect(style.imports[0].config.showPlaceLabels).toBe(false); + }); + + it("should reject a custom_source keyed like one of the builder's own", async () => { + // custom_sources is merged last, so a collision replaces the basemap source rather + // than sitting beside it — and every basemap layer then reads the caller's data. + const composite = await tool.run({ + style_name: 'S', + base_style: 'standard', + custom_sources: { + composite: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [{ layer_type: 'water', action: 'show' }] + } as StyleBuilderToolInput); + + const text = composite.content[0].text as string; + expect(text).toContain('Reserved source id'); + expect(text).toContain('`composite`'); + expect(text).toContain('my-composite'); + expect(text).not.toContain('Style Built Successfully'); + + // It is about what lands in `style.sources`, not about layer wiring: a declared source + // clobbers the basemap whether or not any layer points at it. + const unreferenced = await tool.run({ + style_name: 'S', + base_style: 'standard', + custom_sources: { + composite: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [] + } as StyleBuilderToolInput); + expect(unreferenced.content[0].text).toContain('Reserved source id'); + + // "satellite" is reserved only where the base actually declares it. + const onSatelliteBase = await tool.run({ + style_name: 'C', + base_style: 'satellite-v9', + custom_sources: { + satellite: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [{ layer_type: 'water', action: 'show' }] + } as StyleBuilderToolInput); + expect(onSatelliteBase.content[0].text).toContain('Reserved source id'); + + const onVectorBase = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + custom_sources: { + satellite: { type: 'geojson', data: 'https://example.com/z.geojson' } + }, + layers: [ + { + layer_type: 'My raster-ish data', + source_id: 'satellite', + render_type: 'fill', + action: 'color', + color: '#ff0000' + } + ] + } as StyleBuilderToolInput); + expect(onVectorBase.content[0].text).toContain( + 'Style Built Successfully' + ); + }); + + it('should keep hide working by omission on Classic', async () => { + // There the tool authors every layer, so leaving one out is what hides the feature. + const result = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + layers: [ + { layer_type: 'poi_label', action: 'hide' }, + { layer_type: 'water', action: 'color', color: '#0099ff' } + ] + } as StyleBuilderToolInput); + + const style = JSON.parse( + (result.content[0].text as string).match(/```json\n([\s\S]*?)\n```/)![1] + ); + expect( + style.layers.some((l: any) => l['source-layer'] === 'poi_label') + ).toBe(false); + expect(style.layers.some((l: any) => l['source-layer'] === 'water')).toBe( + true + ); + }); + + it('should point at the config property when recoloring the basemap on Standard', async () => { + // The layer is still generated — an overdraw is right when the recolour is filtered to a + // subset the config cannot express — but the caller is told it is a second copy. + const result = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [{ layer_type: 'water', action: 'color', color: '#0099ff' }] + } as StyleBuilderToolInput); + + const text = result.content[0].text as string; + expect(text).toContain('colorWater'); + expect(text).toContain("second copy over the basemap's own"); + expect(text).toContain('Style Built Successfully'); + }); + + it('should honour light/dark and imagery from the Classic base name', async () => { + const build = async (baseStyle: string) => { + const result = await tool.run({ + style_name: 'C', + base_style: baseStyle, + layers: [ + { layer_type: 'place_label', action: 'show', render_type: 'symbol' } + ] + } as StyleBuilderToolInput); + return JSON.parse( + (result.content[0].text as string).match( + /```json\n([\s\S]*?)\n```/ + )![1] + ); + }; + + // All eight Classic values used to produce byte-identical output, so "dark-v11" was a + // light map and "satellite-v9" had no imagery. A Classic base is not an import, so the + // builder cannot reproduce the named style — only what the name states outright is + // honoured, using the two land colours the tool already had. + const light = await build('streets-v12'); + const dark = await build('dark-v11'); + expect(light.layers[0].paint['background-color']).toBe('#f8f4f0'); + expect(dark.layers[0].paint['background-color']).toBe('#1a1a1a'); + + // Deliberately equivalent: nothing available to the builder separates these two, and + // inventing a difference would attribute made-up cartography to a Mapbox style. + const night = await build('navigation-night-v1'); + expect(night.layers[0].paint['background-color']).toBe('#1a1a1a'); + + // A dark base needs a label colour that reads on it, or the text is black on black. + const darkLabels = dark.layers.find((l: any) => l.type === 'symbol'); + expect(darkLabels.paint['text-color']).toBe('#ffffff'); + expect(darkLabels.paint['text-halo-color']).toBe('#000000'); + + // A satellite base is imagery — a flat colour is not a stand-in for it. + const satellite = await build('satellite-v9'); + expect(satellite.sources.satellite).toEqual({ + type: 'raster', + url: 'mapbox://mapbox.satellite', + tileSize: 256 + }); + expect(satellite.layers[0]).toEqual({ + id: 'satellite', + type: 'raster', + source: 'satellite' + }); + expect(satellite.layers.some((l: any) => l.type === 'background')).toBe( + false + ); + + // The other imagery base is imagery too — and it is dark, so its labels have to read + // against the photograph rather than against the land colour it never draws. + const satelliteStreets = await build('satellite-streets-v12'); + expect(satelliteStreets.sources.satellite).toEqual( + satellite.sources.satellite + ); + expect(satelliteStreets.layers[0]).toEqual(satellite.layers[0]); + expect( + satelliteStreets.layers.find((l: any) => l.type === 'symbol').paint[ + 'text-color' + ] + ).toBe('#ffffff'); + + // Still self-contained: a Classic base never becomes a style import. + for (const style of [light, dark, night, satellite, satelliteStreets]) { + expect(style.imports).toBeUndefined(); + } + }); + + it('should redirect an empty Classic build instead of shipping a bare background', async () => { + const result = await tool.run({ + style_name: 'C', + base_style: 'dark-v11', + layers: [] + } as StyleBuilderToolInput); + const text = result.content[0].text as string; + + // A Classic base authors nothing, so this used to be a lone background layer reported as + // "Style Built Successfully" — and the likeliest reading of the request is "give me + // dark-v11", which is a reference to an existing style rather than a build. + expect(text).not.toContain('Style Built Successfully'); + expect(text).toContain('Nothing was generated'); + expect(text).toContain('mapbox://styles/mapbox/dark-v11'); + expect(text).toContain( + 'https://docs.mapbox.com/map-styles/guides/classic-styles/' + ); + // A dark Classic base is the case where Standard has a direct equivalent. + expect(text).toContain('lightPreset'); + // And the third reading: they really do want a stack they author themselves. + expect(text).toContain('place_label'); + + // An imagery base gets the extra option, since standard-satellite is what it wants. + const satellite = await tool.run({ + style_name: 'C', + base_style: 'satellite-v9', + layers: [] + } as StyleBuilderToolInput); + expect(satellite.content[0].text as string).toContain( + 'mapbox://styles/mapbox/standard-satellite' + ); + + // Standard is exempt: a config-only style with no layers of its own is the normal shape + // there, because the import supplies the map. + const standard = await tool.run({ + style_name: 'S', + base_style: 'standard', + layers: [], + standard_config: { lightPreset: 'night' } + } as StyleBuilderToolInput); + expect(standard.content[0].text as string).toContain( + 'Style Built Successfully' + ); + }); + + it('should name the basemap features a thin Classic stack leaves undrawn', async () => { + const result = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + layers: [{ layer_type: 'road', action: 'color', color: '#ffffff' }] + } as StyleBuilderToolInput); + const text = result.content[0].text as string; + + // Built, not rejected — one layer over a background is a legitimate thing to want — but the + // shortfall is invisible in the JSON, so it is named. + expect(text).toContain('Style Built Successfully'); + const shortfall = text + .split('\n') + .find((line) => line.includes('draws nothing for'))!; + expect(shortfall).toContain('"water"'); + expect(shortfall).toContain('"place_label"'); + // The one feature that *was* asked for is not reported as missing. + expect(shortfall).not.toContain('"road"'); + + // A full stack has nothing to report. + const complete = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + layers: [ + { layer_type: 'water', action: 'color', color: '#a0c8f0' }, + { layer_type: 'landuse', action: 'color', color: '#d0e0d0' }, + { layer_type: 'road', action: 'color', color: '#ffffff' }, + { layer_type: 'building', action: 'color', color: '#e0e0e0' }, + { layer_type: 'place_label', action: 'show', render_type: 'symbol' } + ] + } as StyleBuilderToolInput); + expect(complete.content[0].text as string).not.toContain( + 'draws nothing for' + ); + }); + + it('should let label_color beat a dark mode the base name implied', async () => { + // The dark default and an explicit label_color arrive from different places now, so + // their precedence is worth pinning: label_color is the more specific of the two. + const result = await tool.run({ + style_name: 'C', + base_style: 'dark-v11', + global_settings: { label_color: '#ffcc00' }, + layers: [ + { layer_type: 'place_label', action: 'show', render_type: 'symbol' } + ] + } as StyleBuilderToolInput); + + const style = JSON.parse( + (result.content[0].text as string).match(/```json\n([\s\S]*?)\n```/)![1] + ); + const labels = style.layers.find((l: any) => l.type === 'symbol'); + expect(labels.paint['text-color']).toBe('#ffcc00'); + // The halo still follows the mode, so the chosen colour stays legible on dark land. + expect(labels.paint['text-halo-color']).toBe('#000000'); + expect(style.layers[0].paint['background-color']).toBe('#1a1a1a'); + }); + + it('should let global_settings override what the Classic base implies', async () => { + const result = await tool.run({ + style_name: 'C', + base_style: 'dark-v11', + global_settings: { mode: 'light' }, + layers: [ + { layer_type: 'place_label', action: 'show', render_type: 'symbol' } + ] + } as StyleBuilderToolInput); + + const style = JSON.parse( + (result.content[0].text as string).match(/```json\n([\s\S]*?)\n```/)![1] + ); + // The base name only decides the default mode, so an explicit mode wins and the land + // colour follows it — a dark background under light-mode labels is the state this avoids. + expect(style.layers[0].paint['background-color']).toBe('#f8f4f0'); + expect( + style.layers.find((l: any) => l.type === 'symbol').paint['text-color'] + ).not.toBe('#ffffff'); + }); + + it('should apply label_color on Classic in precedence order', async () => { + const run = async ( + globalSettings: Record, + layerColor?: string + ) => { + const result = await tool.run({ + style_name: 'C', + base_style: 'streets-v12', + global_settings: globalSettings, + layers: [ + { + layer_type: 'place_label', + action: layerColor ? 'color' : 'show', + color: layerColor, + render_type: 'symbol' + } + ] + } as StyleBuilderToolInput); + const style = JSON.parse( + (result.content[0].text as string).match( + /```json\n([\s\S]*?)\n```/ + )![1] + ); + return style.layers.find((l: any) => l.type === 'symbol').paint; + }; + + // Each of these has to beat the generic per-property default, which already put a + // literal text-color in place — deferring to it made label_color a no-op. + expect((await run({ label_color: '#ff00ff' }))['text-color']).toBe( + '#ff00ff' + ); + expect((await run({ mode: 'dark' }))['text-color']).toBe('#ffffff'); + expect( + (await run({ label_color: '#ff00ff', mode: 'dark' }))['text-color'] + ).toBe('#ff00ff'); + // A color set on the layer itself is more specific than a style-wide default. + expect( + (await run({ label_color: '#ff00ff' }, '#00ff00'))['text-color'] + ).toBe('#00ff00'); + }); + + it('should drive icons off each layers own icon field rather than a literal', async () => { + const iconFor = async (layerType: string) => { + const result = await tool.run({ + style_name: 'I', + base_style: 'standard', + layers: [ + { layer_type: layerType, action: 'show', render_type: 'symbol' } + ] + } as StyleBuilderToolInput); + const style = JSON.parse( + (result.content[0].text as string).match( + /```json\n([\s\S]*?)\n```/ + )![1] + ); + return style.layers.find((l: any) => l.type === 'symbol')?.layout?.[ + 'icon-image' + ]; + }; + + // These branches were previously keyed on 'poi_labels' and 'transit' — neither is a + // real source layer, so they were unreachable and every symbol layer fell through to + // a hardcoded "marker-15", giving every feature the same generic pin. + expect(await iconFor('poi_label')).toEqual(['get', 'maki']); + expect(await iconFor('airport_label')).toEqual(['get', 'maki']); + + // Place labels are text only; a marker on every city was never intended. + expect(await iconFor('place_label')).toBeUndefined(); + }); + + it('should infer a slot from the layer type when none is given', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Slot Inference Test', + base_style: 'standard', + layers: [ + { + layer_type: 'landuse', + filter_properties: { class: 'park' }, + action: 'color', + color: '#00ff00', + render_type: 'fill' + }, + { + layer_type: 'road', + action: 'color', + color: '#ffaa00', + render_type: 'line' + }, + { + layer_type: 'poi_label', + action: 'show', + render_type: 'symbol' + } + ] + }; + + const result = await tool.run(input); + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + const byType = (type: string) => + style.layers.find((l: any) => l.type === type); + + // Area fills belong under the road network, lines above roads but behind + // labels, symbols above POI labels where markers stay legible. + expect(byType('fill').slot).toBe('bottom'); + expect(byType('line').slot).toBe('middle'); + expect(byType('symbol').slot).toBe('top'); + }); + + it('should set emissive strength on custom fill/line/circle layers so they survive the night preset', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Emissive Test', + base_style: 'standard', + layers: [ + { + layer_type: 'landuse', + filter_properties: { class: 'park' }, + action: 'color', + color: '#00ff00', + render_type: 'fill' + }, + { + layer_type: 'road', + action: 'color', + color: '#ffaa00', + render_type: 'line' + }, + { + layer_type: 'poi_label', + action: 'show', + render_type: 'symbol' + } + ], + standard_config: { lightPreset: 'night' } + }; + + const result = await tool.run(input); + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + const byType = (type: string) => + style.layers.find((l: any) => l.type === type); + + // These three default to 0, meaning the scene lights them into shadow. + expect(byType('fill').paint['fill-emissive-strength']).toBe(1); + expect(byType('line').paint['line-emissive-strength']).toBe(1); + + // Symbols already default to 1, so nothing should be added for them. + expect(byType('symbol').paint['icon-emissive-strength']).toBeUndefined(); + expect(byType('symbol').paint['text-emissive-strength']).toBeUndefined(); + }); + + it('should not add emissive strength to Classic styles, which have no lighting to shadow them', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Classic Emissive Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'landuse', + filter_properties: { class: 'park' }, + action: 'color', + color: '#00ff00', + render_type: 'fill' + } + ] + }; + + const result = await tool.run(input); + const text = result.content[0].text as string; + const style = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); + + const fillLayer = style.layers.find((l: any) => l.type === 'fill'); + expect(fillLayer.paint['fill-emissive-strength']).toBeUndefined(); + }); + + it('should redirect global_settings.mode dark to lightPreset on Standard', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Dark Mode Test', + base_style: 'standard', + layers: [ + { + layer_type: 'water', + action: 'color', + color: '#0099ff', + render_type: 'fill' + } + ], + global_settings: { mode: 'dark' } + }; + + const result = await tool.run(input); + const text = result.content[0].text as string; + + expect(text).toContain('lightPreset'); + expect(text).toContain('night'); + }); + it('should always default to Standard style when not specified', async () => { const input: StyleBuilderToolInput = { style_name: 'Default Style Test',