## Proposed change
The CARTO basemap we have been using started rendering an "A…PI KEY REQUIRED" overlay into its tiles, which breaks every map in the frontend (#53800). Rather than hunting for another free raster host, this moves the base map to vector tiles, which we wanted to do anyway.
The base map is now drawn by MapLibre GL through [`maplibre-gl-leaflet`](https://github.com/maplibre/maplibre-gl-leaflet), on the [Shortbread](https://shortbread-tiles.org/) vector tiles the OpenStreetMap Foundation serves. Everything else stays on Leaflet — markers, clustering, zone editing, paths, the scale control — so only the base layer changes and the diff stays small. Going fully native MapLibre would mean replacing `leaflet.markercluster` and `leaflet-draw` as well, and we would still need Leaflet around for the fallback below.
**Fallback.** MapLibre needs WebGL2, which rules out iOS below 15 (iPad 2/3/4 and Air 1, common as wall panels) and devices whose GPU driver the browser blocklists. MapLibre draws raster sources through WebGL too, so the fallback has to stay a plain Leaflet tile layer. Detection is a runtime `canvas.getContext("webgl2")` probe, deliberately not gated on the bundle: the legacy bundle is picked by user agent, and nearly everything in that range does have WebGL2.
That fallback keeps **CARTO** for now — the basemap this change replaces, overlay and all. OSM's raster tiles block a browser that sends no `Referer`, and they do it with HTTP 200 and a PNG reading "Access blocked", so nothing would even look broken. The only referrer a browser can set is its origin, which for a Nabu Casa instance identifies the installation. For the devices on this path CARTO is not a regression — it is exactly what they see today — and it needs no referrer. It can move to OSM raster once something server-side proxies the tiles with an application User-Agent.
**Self-hosted assets.** `vector.openstreetmap.org` sets CORS headers on the tile endpoint only, so the style, its SDF glyphs and its sprites are served from our own `/static/map/`. A new gulp task (`build-map-assets`) assembles them from pinned, sha256-verified VersaTiles releases and generates a light (`colorful`) and dark (`eclipse`) style with `@versatiles/style`. Self-hosting is also the better end state: it is one less host that can disappear on us, and it makes the styles ours to theme.
Shipped size is **5.4 MB** (102 glyph files, 4 sprites, 2 styles). The full Noto Sans SDF set is 74 MB; CJK, kana and hangul are rendered with a font from the device via `localIdeographFontFamily`, which is 90% of that, and bold is limited to U+0000–04FF because the style only uses it for motorway shields. Coverage was checked against what OSM serves: the sprites are byte-identical, and the glyph codepoint sets match per range.
MapLibre is a lazily loaded chunk of ~276 KB gzip, fetched only when a map is shown on a WebGL2 device.
**Referrer: none.** The OSMF vector tile policy asks for one, and this sends none. The only referrer a browser can set is its origin, and for a Nabu Casa instance that origin is a per-installation identifier — not something to hand a third party on every tile request. Measured across several tiles and the TileJSON, `vector.openstreetmap.org` serves a browser with no `Referer` at all, so nothing needs it on this path. Identifying Home Assistant properly needs a server to do it, with a real application `User-Agent` — the OSMF accepts that in place of a referrer (measured), and a browser fundamentally cannot set one. This is worth telling the OSMF rather than leaving them to notice.
**Dark mode** is now a real dark cartography instead of a CSS `invert()` filter. The filter is still applied, but scoped to the raster fallback.
Two things the swap forced, both easy to miss:
- The Leaflet map gets an explicit zoom range. It used to inherit one from the raster tile layer, and `markercluster` throws `Map has no maxZoom specified` without it.
- The style is fetched and its sprite URL made absolute — MapLibre rejects a relative one. The glyph URL stays relative so its `{fontstack}`/`{range}` placeholders are not percent-encoded.
`maplibre-gl` is pinned to 5.24.0 on purpose. v6 is ESM-only and derives its worker URL from `import.meta.url`, which rspack replaces with a build-machine `file://` path; it then falls back to `new Worker("")` and nothing renders. v5 inlines its worker, and is also what the OSM demo runs.
## Screenshots
New vector tiles:
<img width="2300" height="1996" alt="image" src="https://github.com/user-attachments/assets/69178d5b-94a4-4870-bb0e-d7ebb5ca2705" />
<img width="2308" height="1996" alt="image" src="https://github.com/user-attachments/assets/3c9d580a-9fa0-4e9d-9655-8d187a3f77dd" />
New raster tiles (fallback for older devices):
<img width="2306" height="1998" alt="image" src="https://github.com/user-attachments/assets/feceef72-5eed-4b09-bbe9-5ec3dad9fc8d" />
<img width="2310" height="1996" alt="image" src="https://github.com/user-attachments/assets/18a90071-d024-49a0-8654-ffb9172d2f90" />
## Type of change
- [ ] Dependency upgrade
- [x] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (thank you!)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests
## Additional information
- This PR fixes or closes issue: fixes #53800
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to backend pull request:
Worth checking before merge:
- **CSP.** MapLibre v5 creates its worker from a blob URL. If core sends a CSP without `worker-src blob:`, the map breaks. `maplibre-gl-csp.js` is the escape hatch if so.
- **WebGL contexts.** Browsers keep roughly 16 per page and drop the oldest, which a dashboard full of map cards will hit. Measured with twenty cards: the first four lost their context and never got it back, because nothing frees a slot for MapLibre to reclaim. Those now fall back to raster tiles after a grace period, so a transient loss keeps its vector layer. Measured again: sixteen vector, four raster, none blank.
- The choice of `colorful`/`eclipse` is a design decision that has not been made yet; both are one constant in `build-scripts/gulp/map-assets.js`.
- Tiles resolve through [`shortbread_v1/tilejson.json`](https://vector.openstreetmap.org/shortbread_v1/tilejson.json) rather than a hardcoded URL, as the OSMF asks. That means one extra request before tiles start, and no tiles at all if the TileJSON is unreachable, where a hardcoded URL would still have worked. Their `stale-if-error=86400` covers most of that.
## Checklist
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] There is no commented out code in this PR.
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
If user exposed functionality or configuration variables are added/changed:
- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]
To help with the load of incoming pull requests:
- [ ] I have reviewed two other [open pull requests][prs] in this repository.
[prs]: https://github.com/home-assistant/frontend/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+sort%3Acreated-desc+review%3Anone+-status%3Afailure
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr