Skip to content

Cartography standard first - #73

Draft
nslavin wants to merge 12 commits into
mainfrom
cartography-standard-first
Draft

Cartography standard first#73
nslavin wants to merge 12 commits into
mainfrom
cartography-standard-first

Conversation

@nslavin

@nslavin nslavin commented Jul 28, 2026

Copy link
Copy Markdown

What

mapbox-cartography was rewritten around the Standard style, but the other 18 skills still taught the workflow it moved away from. This propagates the Standard-first doctrine across the repo so map-design guidance is uniform, and corrects two factual errors found while doing it.

Evals

91.5% vs a 90.3% baseline. Read cautiously: added expectations raise maxScore, and judge variance is ~±4pp — three untouched skills moved +3 to +6. The defensible results are the three diagnosed regressions, now at 94.9%, 94.4%, and 98.1%.

Reviewing

Commits are split content-then-tests, so the doctrine change and the test change read independently. Suggested order: cartography (b06c103) first, since everything else defers to it.

Out of scope

mapbox-location-grounding scores 41.7%, with 5 of 8 evals under 60%. Pre-existing and untouched here — worth its own issue.

evals/baseline.json is deliberately not promoted, so this PR's delta stays measurable. Promote it after merge.

nslavin and others added 12 commits July 28, 2026 14:21
The skill taught a Classic-style workflow: hand-order a 10-layer stack and
copy fixed hex palettes. That is no longer the recommended way to design a
Mapbox map, and it steered agents away from the Standard style entirely.

SKILL.md is now config-first and platform-independent. The style spec is the
shared contract, so every layer and expression example is style-spec JSON and
the per-SDK imperative calls (GL JS / Android / iOS) moved to an appendix.

Substantive additions:

- The Standard config surface — theme, lightPreset, visibility toggles,
  densityPointOfInterestLabels, and the color* overrides.
- Slot placement for custom layers, with emissive strength. Routes and
  choropleths go in `middle`, markers in `top`; a layer with no slot lands
  above the labels.
- Dark mode built on lightPreset. Config colors are day values that Standard
  re-derives per preset, so handing it an already-dark colorLand
  double-darkens to near-black — the failure this section exists to prevent.
- A substyle priority ladder (in-config theme -> Standard-based style ->
  Classic) so the cheapest option gets tried first.
- Custom color themes via LUT, including that a LUT is applied last and
  regrades your own layers along with the basemap.
- Color guidance as relationships (lightness steps, hue + saturation floors
  for water) rather than fixed hex values, so it survives restyling.

Classic styles keep a section of their own — the Static Images API can't
render Standard, so the hand-authored layer order and palette relationships
are still needed, just scoped to where they apply.

references/scenarios.md becomes 16 segment archetypes with ready configs, and
references/performance-testing.md becomes data-source tiers, source and layer
budgets, the Web-only marker-drift rules, and a pre-ship QA checklist.

The three evals asserted the old model — layer-order-by-hand and the Classic
dark hex palette — so they would have failed against this content. Replaced
with four that test slot placement, the double-darkening trap, a data-viz
base, and the Classic/Static Images escape hatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evals 1 and 4 asked the question the skill answers ("where do these layers
go?"). Rewritten so they describe the symptom a developer actually arrives
with — layers drawing over the labels and a route vanishing at night, and a
server-rendered PNG that needs a hand-authored style — leaving the model to
identify the cause rather than being handed it.
The rule named only fill- and line-emissive-strength. Checked against the
style spec, the defaults split two ways:

  fill- / line- / circle-emissive-strength   default 0  -> must be set to 1
  icon- / text-emissive-strength             default 1  -> nothing to do

So `circle` was a genuine omission — cluster circles, bubble maps, and
user-location dots all fall into shadow at dusk/night without it — while
symbol layers were never at risk. The rule now states both halves, because
the other skills lean on it for cluster and marker layers.

Also:

- Flutter joins the platform scope. The repo ships mapbox-flutter-patterns
  and the SDK exposes the same contract (MapboxStyles.STANDARD,
  style.setStyleImportConfigProperty, a slot argument on layer constructors),
  so the design guidance applies there too.
- Marker guidance splits into two axes. Point count decides how to render
  (marker -> GL layer -> vector tileset); it does not decide whether to
  cluster, which depends on whether points visibly overlap at the zooms
  users actually use. The old single ladder presented clustering as a
  performance rung between symbol layers and tilesets, a progression the
  platform doesn't have — the docs put the tileset benefit in the thousands
  and never route through clustering.
- text-font takes a fontstack, not a font name. Note the fallback behavior,
  since an unresolvable face fails silently.
- AGENTS.md restored as a condensed quick reference, mirroring the rewritten
  SKILL.md. Every other skill carries one, and dropping cartography's left
  the design source of truth as the only skill without a quick reference.
- Drop references/scenarios.md and references/performance-testing.md, which
  the Standard-first rewrite left stranded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ength

These four skills predate the cartography rewrite and still taught the
workflow it moved away from. Two omissions ran through every custom-layer
example in the repo:

- `slot` appeared zero times outside cartography, across ~40 addLayer calls.
  A layer with no slot draws above the whole basemap, including street
  labels — so every choropleth, route, and marker example shipped with
  broken layer placement on the style these same skills tell you to load.
- Emissive strength appeared zero times, so every fill, line, and circle
  overlay went nearly invisible at the dusk and night light presets.

Both are now set on every example, with the reason inline.

mapbox-style-patterns changed most. Each pattern now leads with Standard
config plus custom layers in slots, instead of a hand-authored 10-layer
stack against the mapbox-streets source. dark-mode.md was the sharpest case:
it built a near-black Classic stack at #0a0a0a, below cartography's own
L 8-14% floor, where the answer is lightPreset:'night'. Classic stacks stay
as labeled fallbacks for the cases that need them — Static Images API
rasters, per-layer paint control, deliberate 2D. Legacy {base, stops}
functions become interpolate expressions throughout.

Colorblind-unsafe ramps shipped as recommended, most visibly in
data-visualization/SKILL.md, which labeled a red-to-green RdYlGn ramp
"Good: Diverging" six lines above its own "Avoid: Red-green" warning. Those
are RdBu/PuOr/BrBG now, with the ColorBrewer scheme named so compliance is
checkable. Delivery-zone status keeps the traffic-light convention but adds a
dash pattern, since hue alone fails for ~1 in 12 men — with a note that
data-driven line-dasharray is GL JS only, and a portable per-status-layer
alternative for mobile.

Also: choropleth fill-opacity capped at 0.7 including the top of a
data-driven ramp (an uncapped ramp hid the basemap under landslide
districts); the invalid `'visibility': ['step', ['zoom'], ...]` snippet
replaced, since visibility takes no expressions; symbol collision properties
corrected, including a POI pattern that set icon-allow-overlap:false while
requiring every POI to be visible; and style-quality's pre-production
checklist gains a slot/emissive audit, all four light presets, and the
color-encoding rules its contrast checks never covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of the four platform skills mentioned the Standard config surface.
setConfigProperty appeared once in the whole repo, inside a code comment;
setStyleImportConfigProperty appeared zero times. Dark mode was taught four
different ways across them — loadStyle(Style.DARK), .mapStyle(.dark), a
dark-v11 URL on a web component, and a full setStyle() reload — each of
which swaps the entire style, which is both the most expensive call you can
make on a live map and a silent way to drop your configuration.

All four now set lightPreset on the "basemap" import instead, bound to the
platform's appearance signal: prefers-color-scheme on web, uiMode on
Android, colorScheme on iOS, platformBrightness on Flutter. iOS also gets
the SwiftUI .standard(lightPreset:) form.

Custom-layer examples gain slot and emissive strength in each SDK's idiom:
slot("middle") + lineEmissiveStrength(1.0) in Kotlin, layer.slot = .middle +
.constant(1) in Swift, slot: 'middle' in Dart. Both carry the day-values
caveat, since a pre-darkened colorLand plus lightPreset:'night' double-darkens
to near-black.

mapbox-web-performance-patterns had a genuine conflict with cartography
rather than a gap: it promoted bare minzoom cutoffs under a ✅ as best
practice, while cartography forbids hard cutoffs. Both were right about
different things — the draw-call saving is real, and so is the visual pop —
so the guidance now keeps the cull but sets the bound 1-2 levels low and
fades opacity across that band, which costs one expression over two zoom
levels. Its marker thresholds also disagreed with two other skills; all
three now use one ladder, with clustering split out as the legibility
decision it is.

Web-integration keeps setStyle() where it is genuinely correct — a
style-swap prop — and adds a light-preset attribute for the appearance case
it was being misused for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both migration skills pointed new users at a Classic style as the default
destination, which teaches the wrong starting point to exactly the audience
with no prior Mapbox habits. streets-v12 becomes mapbox/standard throughout,
with Classic kept where a reason exists and the reason stated.

mapbox-google-maps-migration translated a Google dark map into
setStyle('mapbox://styles/mapbox/dark-v11'), and its API table mapped
setMapTypeId to setStyle. Both now route through config: the table points at
setConfigProperty for appearance and reserves setStyle for changing basemap
family. A Google `styles` array should not be ported rule-by-rule into
setPaintProperty calls — a muted base is theme:'faded', a dark map is
lightPreset:'night', hidden POIs are showPointOfInterestLabels:false — so
that mapping is spelled out.

mapbox-maplibre-migration gets the same treatment plus the part specific to
its audience: a MapLibre style JSON is a full hand-authored layer stack, and
the migration target is Standard config, not a ported stack. Custom layers
carry over unchanged since the style spec is shared, but on Standard they
need a slot — MapLibre's flat layer order never required one, so this is the
migration's most likely surprise.

Cluster and shape layers in both skills gain slots and emissive strength.
search-integration switches its map inits to Standard; its result pins stay
HTML markers, which is correct for the handful of results it renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mapbox-mcp-devkit-patterns opened with "Create a dark mode Mapbox style with
3D buildings" as its worked example, which steers toward generating and then
maintaining a style for something Standard already does with one config
property. create_style_tool emits a Classic style JSON — no slots, no config
surface — so it now sits behind a check: map the intent onto Standard config
first, and reach for a new style only when you need a server-rendered raster,
per-layer paint control config can't express, or a deliberate 2D fallback.
The iterative-development pattern shows both paths, including a real estate
request that resolves entirely in config.

The root AGENTS.md descriptions for mapbox-cartography and
mapbox-style-patterns were stale — cartography's still read "color theory,
visual hierarchy, typography" with no mention of the Standard style or its
config surface. Both now match what the skills actually cover, including
cartography's Classic-style and raw-style-JSON guidance. The performance
priorities line drops the count-based clustering rule this PR removes
elsewhere, in favor of the tileset threshold and clustering-on-overlap.

The index stays an index: it points at skills and doesn't restate their
contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two evals asserted things the updated skills contradict, and would have
failed a correct answer:

- web-performance #2 required clustering for 75,000 points. The docs put GL
  layers at "not likely to cause a noticeable performance impact" for
  hundreds and route the escalation to a vector tileset in the thousands —
  clustering is never on that path. Worth noting this eval was already
  self-contradicting: its expected_output said 75,000 does NOT need
  clustering and cited a third threshold ("500-100,000"), so the two halves
  disagreed. Both now describe the split axes.
- data-visualization #3 asserted a <1MB / 1-10MB / >10MB rule that
  disagreed with its own references/performance.md and with
  web-performance. Now <5 / 5-20 / >20 MB everywhere.

store-locator #2 no longer hinges on a count triggering clustering, and
google-maps #1's slot expectation no longer demands a circle property from
an answer that correctly uses a symbol layer.

Slot, emissive, opacity-cap, and colorblind expectations added to seven
existing evals. One of these immediately earned its place: the choropleth
eval caught a data-driven fill-opacity ramp topping out at 0.85, which is
why the 0.7 cap is now stated as a rule rather than a code comment.

Two new evals cover the failure modes most likely to regress:

- data-visualization #4 — layers drawing over the labels and vanishing in
  dark mode, i.e. missing slot and missing emissive strength together.
- web-integration #4 — a dark-mode prop wired to a light/dark style pair.
  It shares the lifecycle bug of #3, so a model that only knows the
  two-effect pattern scores partially; full credit needs setConfigProperty.
  #3 is untouched, so the setStyle-for-a-style-prop case stays covered.

Measured 91.5% against a 90.3% baseline. Read that cautiously: adding
expectations raises maxScore, and run-to-run judge variance is around ±4pp —
three skills that were not touched at all moved +3 to +6. The defensible
results are the three diagnosed regressions, now at 94.9%, 94.4%, and 98.1%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fontstack/fontstacks are Mapbox's own vocabulary (see the Manage your
fontstacks guide); desaturates/desaturation are inflections of the already
allowed desaturate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill listed create_style_tool as the tool for "creating/modifying styles"
and never mentioned style_builder_tool in its routing at all — it appeared once,
as a table row in AGENTS.md. That matters now that style_builder_tool is where
the Standard doctrine is enforced: it defaults to standard, takes standard_config,
and supplies an explicit slot plus emissive strength on custom fill/line/circle
layers. An agent following the old routing bypassed every one of those, because
create_style_tool uploads whatever JSON it is handed and enforces nothing.

Also corrects the mechanism described in design-patterns.md. It said
create_style_tool "produces a Classic style JSON", which reads as though the
tool generates something. It doesn't generate anything — it uploads. The Classic
outcome is real but the cause is different: a hand-authored version/sources/
layers object has no imports, so it is Classic by omission. Worth stating
precisely, because the fix follows from the cause — build with the builder
rather than avoid the uploader.

Verified with the skill's own evals: 38/39 (97.4%), with eval #1, the style
workflow this change touches, at 15/15. The single PARTIAL is on eval #2's
production token scopes, untouched here. Baseline was 36/36, but maxScore moved
36 → 39 when an expectation was added earlier on this branch, so the aggregate
is not directly comparable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documented config surface listed 20 of Standard's 41 configuration
properties. Audited every Standard config claim in the repo against the
Standard API reference and guide, and added the 21 that were missing:

- 3D: show3dTrees, show3dFacades
- Visibility: showLandmarkIconLabels, showAdminBoundaries, showIndoor,
  showIndoorLabels
- POI controls: colorModePointOfInterestLabels,
  backgroundPointOfInterestLabels, fuelingStationModePointOfInterestLabels
- Land use: colorCommercial, colorEducation, colorMedical, colorIndustrial
- colorAdminBoundaries, and the six feature-state colors
  (colorBuildingHighlight/Select, colorPlaceLabelHighlight/Select,
  colorIndoorLabelHighlight/Select)
- standard-satellite's showRoadsAndTransit

Also corrects three factual errors:

- "A layer with no slot draws above everything, including labels" is only
  true outside the globe projection. Under globe — GL JS's default — an
  unslotted layer lands below labels. The operational advice is unchanged
  (always set a slot), but the stated reason was wrong, including as a
  grading criterion in the cartography evals.
- theme was enumerated without `custom` in the iOS and Android references.
- standard-satellite was treated as sharing Standard's config surface. It
  has no theme/theme-data, no show3d*, no landmark or indoor toggles, and
  none of the land, water, land-use or building colors.

Adds a featuresets section, without which the feature-state colors have no
meaning, plus version gates for recent properties — an unknown config key
is ignored silently, which reads as "config doesn't work" rather than "your
SDK is too old."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Raw point deltas are not comparable across runs: adding an eval raises a
skill's totalScore and maxScore together, so a quality regression can read
as a point gain. Report percentage-point deltas instead, and warn when the
eval count changed.

An eval id is not a stable identity either — rewriting evals.json can put a
different question behind the same id, and diffing expectation-by-expectation
across two different prompts compares unrelated things. Treat a changed
prompt as a replacement.

Also bumps the default eval and judge model to Sonnet 5. Thinking is on by
default from Sonnet 5 onward and shares the max_tokens budget with the
response, so disable it and raise max_tokens — otherwise long answers
truncate and the judge scores the cut-off text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant