Studio: design a map style, and check it can be read

# 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.

```json
{
"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](/docs/maps)) 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:

```json
{
"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.

```json
{ "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.

```sh
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`:

```json
{
"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](/news/mcp-server).

## What Studio does not do

An honest list:

- **The contrast validator and `text_scale` are not controls in the Studio panel yet.** They live in the theme engine, the theme JSON and the `check_style_contrast` MCP 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-opacity` overrides are not accounted for, so a fill you dropped to 45% opacity is measured at full strength.
- **Only hex colours are parsed.** An `rgb()` or `hsl()` 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.regular` at 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](/studio), pick a base, drag the palette around and press "Drive the route" before you publish. The [maps documentation](/docs/maps) is authoritative on the theme document, the palette slots and the style API, and the [vector tiles and styles write-up](/news/vector-tiles-and-styles) 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.