Studio: design a map style, and check it can be read
MapMap Studio is a browser editor that turns a small JSON theme document into a published, immutable MapLibre style URL you can hand straight to a map client. It runs the same theme engine as the hosted gateway and as our signed offline territory packages, so a style you design in a browser tab renders identically online, on a phone and air-gapped. It also audits your palette against WCAG 2.1 contrast ratios, because a map that looks right on a designer's monitor is not automatically one a driver can read on a windscreen mount in direct sun.
What you edit is a theme document, not a style.json
Studio edits a theme: a small JSON document the engine compiles into a full MapLibre style-spec v8 style.json. You never hand-write the 29 skeleton layers.
{
"name": "midnight-fleet",
"base": "dark",
"palette": { "water": "#0b2038", "roadMajor": "#8a6d3b" },
"layers": {
"building": { "visible": false },
"road-minor": { "paint": { "line-width": 2 }, "minzoom": 10 }
}
}
That is the whole contract. base picks the light or dark default palette. The 19 palette slots (background, water, roadMajor, textPrimary and so on, listed in full in the maps documentation) recolour the whole map at once. Per-layer overrides target the skeleton layer ids by name, merged per key over the compiled defaults: visible, paint, layout, filter, minzoom and maxzoom.
The browser surface is three tabs: Map design (base toggle, palette pickers, per-layer colour, width, opacity and visibility, label fontstack and a flat/globe switch, over a live preview), Navigation for the turn-by-turn look, and Publish. Your working theme autosaves.
Attribution is structural: every compiled style carries "© OpenStreetMap contributors © OpenMapTiles" on its tile source, and validation rejects a theme that drops it.
Why a map design tool ships a contrast validator
Because the failure mode of map design is not ugliness, it is illegibility, and it shows up in a cab rather than in the review. A pale grey road label on a cream background looks tasteful on a designer's desk. The same style on a phone clamped to a windscreen in July is a blank map, and the driver stops reading it and starts guessing.
So the theme engine measures the palette. The maths is WCAG 2.1 exactly: relative luminance, then (L_lighter + 0.05) / (L_darker + 0.05). Two thresholds apply, by what the pair is:
| Pair kind | Threshold | WCAG success criterion | Example pair |
|---|---|---|---|
text | 4.5 | SC 1.4.3 Contrast (Minimum), AA | textPrimary on textHalo |
graphics | 3.0 | SC 1.4.11 Non-text Contrast | water on background |
Eight pairs are audited, chosen from how the compiler wires slots into the skeleton rather than from every combination: label text against its textHalo (the halo is the text's immediate backing, and usually what saves it), textPrimary against the bare background as a halo-free worst case, water names on the water fill, and so on. Each runs against both palettes, so a report is 16 findings.
Be precise about what this buys you: 4.5:1 is a floor measured on a decent screen indoors. Passing it does not prove your map is readable in a sunlit cab, because the cab is harsher than the lab. Failing it proves you have a problem. That asymmetry is still worth a tool.
Our own default palette does not pass, and that is deliberate
Run the audit on the default theme and it returns "passes": false. Nine of the 16 findings fail. Failing pairs sort first:
{
"passes": false,
"findings": [
{
"variant": "light",
"kind": "graphics",
"foreground": "water",
"background": "background",
"description": "water distinguished from land (graphical object)",
"ratio": 1.55,
"threshold": 3.0,
"passes": false
}
]
}
Light water (#a8c8e8) against the light land background (#f4f2ee) measures 1.55:1 against a 3:1 threshold. A map that pushed that pair to 3:1 would not look like a map, it would look like a schematic. WCAG SC 1.4.11 was written for buttons and form fields, not for the relationship between a sea and a coastline.
That is why the check is advisory and will stay advisory. It never fails a compile and never blocks a publish. It hands you a ratio, a threshold and the slot names to adjust, and you decide which findings are cartography and which are a defect. A validator that blocked on its own defaults would only teach you to switch it off.
text_scale is one number that scales every label
text_scale is a global multiplier applied to every label layer's text-size at compile time, bounded to 0.5 to 3.0 inclusive. It defaults to 1.0, and at exactly 1.0 the compiler skips the pass entirely, so existing themes stay byte-identical.
{ "name": "midnight-fleet", "base": "dark", "text_scale": 1.4 }
The detail that matters is that it scales expressions, not just constants. Label sizes are zoom-interpolated, so each numeric stop is multiplied and the curve keeps its shape instead of flattening. It runs last, after per-layer overrides, so a text-size you set yourself is scaled too. Out of range is a validation error, not a clamp.
Drive the route shows you the style at 12 m/s
Studio's Navigation tab has a "Drive the route" button that plays a tilted, course-up chase camera along the active route, over your own theme. A navigation style cannot be judged from a static overhead preview: you are designing for a moving, tilted, low-anchored view, so you should look at one.
A requestAnimationFrame loop advances a virtual vehicle along the route and glues the camera to it. The puck sits low-centre with the road ahead filling the view, using viewport top padding rather than fighting the camera. The bearing is low-pass filtered with a 450 ms time constant, so a roundabout sweeps instead of whipping at every vertex. Grabbing the map pauses it.
Three sliders control it, live and mid-drive:
| Control | Range | Default |
|---|---|---|
pitch | 0–85 degrees | 60 |
zoom | 14–20 | 17.5 |
speedMps | 2–40 m/s | 12 |
They save into the theme under extra.nav.camera, alongside the route line, puck and banner design. The compiler ignores extra entirely, so none of it reaches the compiled style.json, but it travels with the theme document, hosted versions included (capped at 256 KB serialised). The web SDK's NavigationCamera reads the same block as its defaults, explicit options winning. What you tuned in the browser is what ships.
Publishing writes an immutable version
Publishing is one POST and it costs one Standard-class call, 0.05p at the first paid band. The first publish creates the style; every publish after it writes the next immutable version.
curl -fsS -X POST "https://api.mapmap.ai/styles" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Midnight Fleet",
"theme": { "name": "midnight-fleet", "base": "dark", "palette": { "water": "#0b2038" } }
}'
201 Created:
{
"id": "midnight-fleet-9f3a2c",
"version": 1,
"style_url": "https://api.mapmap.ai/styles/midnight-fleet-9f3a2c@1.json",
"theme": { "name": "midnight-fleet", "base": "dark", "palette": { "water": "#0b2038" } }
}
The id is the name's slug plus six hex characters, so two people naming a style "Midnight Fleet" never collide. style_url is immutable, so a client can cache it forever and a deploy can pin it. Reads are public and unmetered, so you can reference the URL from a front end without putting a key in it. The next version is POST /styles/{id} carrying the full theme document under "theme", read-modify-write from GET /styles/{id}/theme. Posting a bare theme is a 400; an invalid theme is a 422 whose problems list names the offending slot and the accepted values.
Agents drive the same surface, not a copy of it
The MCP style tools are thin clients over the same gateway style API and the same validator, so an agent and a human edit one thing.
| Tool | What it does |
|---|---|
list_style_layers | The catalogue of styleable layer ids and palette slots |
get_style | The current theme document plus the latest compiled style URL |
create_style | Publish a theme as version 1 |
set_palette | Change palette slots |
set_layer_paint | Change one layer's paint properties |
check_style_contrast | The WCAG audit, on a hosted style_id or an inline theme |
check_style_contrast sorts failing pairs first, and each finding names the palette slots to pass to set_palette. So an agent can generate a theme, audit it, correct the slots that failed and publish, with no human in the loop. Connection details are in the MCP server write-up.
What Studio does not do
An honest list:
- The contrast validator and
text_scaleare not controls in the Studio panel yet. They live in the theme engine, the theme JSON and thecheck_style_contrastMCP tool. An agent gets the audit more easily than a person with a mouse does, which is the wrong way round. - The audit measures palette slots, not the composited render. Per-layer
fill-opacityoverrides are not accounted for, so a fill you dropped to 45% opacity is measured at full strength. - Only hex colours are parsed. An
rgb()orhsl()palette override is skipped silently, so you get fewer findings rather than a warning. That is a trap and we know it. - There is no first-party high-contrast or colour-blind-safe preset. The report names the slot; you still pick the colour.
- Fonts must stay on a bundled fontstack. Point
fonts.regularat something we do not ship glyphs for and the labels do not fall back, they disappear. - No server-side raster rendering. Clients render the vector tiles themselves.
Try it
Open Studio, pick a base, drag the palette around and press "Drive the route" before you publish. The maps documentation is authoritative on the theme document, the palette slots and the style API, and the vector tiles and styles write-up covers what is under the map you are colouring in.
Map data derives from OpenStreetMap. Credit "© OpenStreetMap contributors" on anything you render or republish; the compiled styles carry it for you.
