diff --git a/.env.example b/.env.example
index d04594c..045d118 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,9 @@
# Copy to .env.local for dev, or set on Vercel for prod.
# Base URL of the Laravel API (no trailing slash, no /api suffix).
VITE_API_BASE=http://localhost:8000
+
+# CARTO basemap API key. CARTO enforces keys on basemaps.cartocdn.com since
+# Aug 2026 (anonymous tiles are watermarked "API KEY REQUIRED"). Free, no
+# account: https://carto.com/basemaps/apikey/ . Leave unset to fall back to
+# keyless OpenStreetMap tiles (light style; dark mode inverts them via CSS).
+VITE_CARTO_API_KEY=
diff --git a/README.md b/README.md
index 1eab2c1..b88b78f 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ CI runs both on every push to `main`.
Targeted at Vercel — auto-detects Vite, uses the `npm run build` command, serves `dist/`. Set `VITE_API_BASE` to the production Laravel URL in Vercel project settings.
+Also set `VITE_CARTO_API_KEY` (free key from ) so the map uses CARTO's theme-matched light/dark basemaps. Without it the map falls back to OpenStreetMap's keyless tiles (dark mode inverts them via CSS) — see `src/components/mapTiles.js`.
+
## License
MIT
diff --git a/index.html b/index.html
index 5af4dad..3a70fd6 100644
--- a/index.html
+++ b/index.html
@@ -1,4 +1,4 @@
-
+
@@ -13,11 +13,15 @@
+
-
+
Roastmap — Canadian specialty coffee
@@ -63,8 +73,7 @@
(function () {
try {
var s = localStorage.getItem('roastmap_theme');
- var dark = s ? s === 'dark'
- : window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var dark = s ? s === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.style.backgroundColor = dark ? '#100c0a' : '#ece5da';
if (dark) {
document.documentElement.classList.add('dark');
diff --git a/src/components/LeafletMap.jsx b/src/components/LeafletMap.jsx
index 2b29184..021da08 100644
--- a/src/components/LeafletMap.jsx
+++ b/src/components/LeafletMap.jsx
@@ -3,16 +3,7 @@ import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import '../styles/leaflet-overrides.css'; // mobile#6: must load AFTER leaflet.css
import ClusterLayer from './ClusterLayer.jsx';
-
-// Theme-matched CARTO basemaps. Everything else on the map (popups, markers,
-// clusters) themes itself via CSS tokens in leaflet-overrides.css; the raster
-// tiles are the one piece that needs an explicit URL swap.
-const TILE_URLS = {
- light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
- dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
-};
-const TILE_ATTR =
- '© OpenStreetMap contributors © CARTO ';
+import { resolveTileLayer } from './mapTiles.js';
/**
* Imperatively re-fits the map when the target bounds change. Must live inside
@@ -53,7 +44,13 @@ function FocusOnReady({ enabled }) {
* first interaction, so the landing page paints instantly and this vendor
* weight stays off the critical path entirely.
*/
-export default function LeafletMap({ markers, targetBounds, isDark = false, focusOnMount = false }) {
+export default function LeafletMap({
+ markers,
+ targetBounds,
+ isDark = false,
+ focusOnMount = false,
+}) {
+ const tiles = resolveTileLayer({ isDark });
return (
{/* key-remount on theme change: react-leaflet does NOT propagate a
mutated `url` prop to a live tile layer, so swapping themes in
- place silently no-ops. A fresh layer per theme is cheap and safe. */}
+ place silently no-ops. A fresh layer per theme is cheap and safe.
+ Provider + URL come from mapTiles.js (CARTO with an API key, else
+ keyless OSM) — see that file for why. */}
diff --git a/src/components/mapTiles.js b/src/components/mapTiles.js
new file mode 100644
index 0000000..03ad4e7
--- /dev/null
+++ b/src/components/mapTiles.js
@@ -0,0 +1,70 @@
+/**
+ * Basemap tile-layer selection for LeafletMap.
+ *
+ * CARTO started enforcing API keys on basemaps.cartocdn.com in late August
+ * 2026: anonymous tile requests still render but carry a repeated
+ * "API KEY REQUIRED" watermark. A key is free (5M tiles/month, requested by
+ * email at https://carto.com/basemaps/apikey/) and is passed as `?key=` on
+ * the same tile URL.
+ *
+ * With VITE_CARTO_API_KEY set (Vercel env for prod, .env.local for dev) we
+ * keep the theme-matched CARTO light/dark styles. Without it we fall back to
+ * the OpenStreetMap standard tiles — keyless, but light-only, so dark mode
+ * inverts them via the `map-tiles--inverted` CSS class in
+ * leaflet-overrides.css. OSM's tile usage policy is fine for this site's
+ * traffic but asks for attribution and a real Referer, both of which a
+ * browser request provides.
+ *
+ * Pure (no Leaflet import) so it stays unit-testable and out of the lazy map
+ * chunk's critical path.
+ */
+
+const CARTO = {
+ light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+ dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ subdomains: 'abcd',
+ maxZoom: 20,
+ attribution:
+ '© OpenStreetMap contributors © CARTO ',
+};
+
+// OSM retired the a/b/c subdomains; the bare host is the supported endpoint.
+const OSM = {
+ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ maxZoom: 19,
+ attribution:
+ '© OpenStreetMap contributors',
+};
+
+export const INVERTED_TILES_CLASS = 'map-tiles--inverted';
+
+export function readCartoKey(env = import.meta.env) {
+ const key = env?.VITE_CARTO_API_KEY;
+ return typeof key === 'string' && key.trim() !== '' ? key.trim() : null;
+}
+
+/**
+ * @param {{ isDark: boolean, cartoKey?: string | null }} opts
+ * @returns {{ provider: 'carto' | 'osm', url: string, attribution: string,
+ * subdomains?: string, maxZoom: number, className?: string }}
+ */
+export function resolveTileLayer({ isDark, cartoKey = readCartoKey() }) {
+ if (cartoKey) {
+ const base = isDark ? CARTO.dark : CARTO.light;
+ return {
+ provider: 'carto',
+ url: `${base}?key=${encodeURIComponent(cartoKey)}`,
+ attribution: CARTO.attribution,
+ subdomains: CARTO.subdomains,
+ maxZoom: CARTO.maxZoom,
+ };
+ }
+
+ return {
+ provider: 'osm',
+ url: OSM.url,
+ attribution: OSM.attribution,
+ maxZoom: OSM.maxZoom,
+ className: isDark ? INVERTED_TILES_CLASS : undefined,
+ };
+}
diff --git a/src/components/mapTiles.test.js b/src/components/mapTiles.test.js
new file mode 100644
index 0000000..2d7dade
--- /dev/null
+++ b/src/components/mapTiles.test.js
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import { resolveTileLayer, readCartoKey, INVERTED_TILES_CLASS } from './mapTiles.js';
+
+describe('resolveTileLayer', () => {
+ it('uses CARTO light/dark with the key on the query string when a key is configured', () => {
+ const light = resolveTileLayer({ isDark: false, cartoKey: 'abc123' });
+ const dark = resolveTileLayer({ isDark: true, cartoKey: 'abc123' });
+
+ expect(light.provider).toBe('carto');
+ expect(light.url).toBe(
+ 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=abc123',
+ );
+ expect(dark.url).toBe(
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=abc123',
+ );
+ expect(light.subdomains).toBe('abcd');
+ expect(light.attribution).toContain('carto.com/attributions');
+ expect(light.className).toBeUndefined();
+ expect(dark.className).toBeUndefined();
+ });
+
+ it('URL-encodes the key', () => {
+ expect(resolveTileLayer({ isDark: false, cartoKey: 'a b&c' }).url).toMatch(/\?key=a%20b%26c$/);
+ });
+
+ it('falls back to keyless OpenStreetMap tiles without a key', () => {
+ const light = resolveTileLayer({ isDark: false, cartoKey: null });
+
+ expect(light.provider).toBe('osm');
+ expect(light.url).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png');
+ expect(light.url).not.toContain('cartocdn');
+ expect(light.attribution).toContain('openstreetmap.org/copyright');
+ expect(light.attribution).not.toContain('CARTO');
+ expect(light.subdomains).toBeUndefined();
+ expect(light.className).toBeUndefined();
+ });
+
+ it('inverts the OSM fallback in dark mode via a CSS class', () => {
+ expect(resolveTileLayer({ isDark: true, cartoKey: null }).className).toBe(INVERTED_TILES_CLASS);
+ });
+});
+
+describe('readCartoKey', () => {
+ it('returns the trimmed key or null', () => {
+ expect(readCartoKey({ VITE_CARTO_API_KEY: ' k1 ' })).toBe('k1');
+ expect(readCartoKey({ VITE_CARTO_API_KEY: '' })).toBeNull();
+ expect(readCartoKey({ VITE_CARTO_API_KEY: ' ' })).toBeNull();
+ expect(readCartoKey({})).toBeNull();
+ expect(readCartoKey(undefined)).toBeNull();
+ });
+});
diff --git a/src/styles/leaflet-overrides.css b/src/styles/leaflet-overrides.css
index 0d5c79e..c89f95e 100644
--- a/src/styles/leaflet-overrides.css
+++ b/src/styles/leaflet-overrides.css
@@ -182,12 +182,16 @@ a.leaflet-popup-close-button:hover {
inset: -6px;
border-radius: 50%;
border: 2px solid #fef6e7;
- box-shadow: 0 0 0 2px #6f4326, 0 4px 8px rgba(0, 0, 0, 0.4);
+ box-shadow:
+ 0 0 0 2px #6f4326,
+ 0 4px 8px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.dark .rm-bean__ring {
border-color: #15100d;
- box-shadow: 0 0 0 2px #e7d3b8, 0 4px 8px rgba(0, 0, 0, 0.7);
+ box-shadow:
+ 0 0 0 2px #e7d3b8,
+ 0 4px 8px rgba(0, 0, 0, 0.7);
}
/* Cluster bubble. Size/font/halo come inline from createClusterIcon
@@ -202,8 +206,13 @@ a.leaflet-popup-close-button:hover {
border: 2px solid #fef6e7;
border-radius: 50%;
font-weight: 700;
- font-family: Inter, system-ui, -apple-system, sans-serif;
- box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25),
+ font-family:
+ Inter,
+ system-ui,
+ -apple-system,
+ sans-serif;
+ box-shadow:
+ 0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25),
0 2px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: transform 120ms ease-out;
@@ -212,7 +221,8 @@ a.leaflet-popup-close-button:hover {
background: #8a5a35;
color: #fef6e7;
border-color: #15100d;
- box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2),
+ box-shadow:
+ 0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2),
0 2px 4px rgba(0, 0, 0, 0.6);
}
@@ -237,3 +247,16 @@ a.leaflet-popup-close-button:hover {
.dark .leaflet-container {
background: #0e0d0c;
}
+
+/*
+ * ---- Keyless basemap fallback (dark mode) ----
+ *
+ * Without VITE_CARTO_API_KEY the map serves OpenStreetMap's standard tiles,
+ * which only come in a light style. mapTiles.js tags that layer with this
+ * class in dark mode so the raster inverts to a dark canvas; hue-rotate
+ * restores land/water colours after the inversion. Scoped to the tile
+ * container only — markers, clusters and popups are separate panes.
+ */
+.leaflet-layer.map-tiles--inverted {
+ filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.95) saturate(0.55);
+}