Browse Source

feat(skills): add mapbox-ops advanced Mapbox GL JS skill

Advanced Mapbox GL JS toolkit for the web (v3): custom markers, thematic
dataviz, 3D/terrain, cinematic camera, style composition, expressions,
performance, and hard-won gotchas across 14 references. Ships a headless
Playwright map verifier (screenshot_map.py) and a stdlib-only §7 staleness
verifier (check-mapbox-facts.py: offline config-enum/tileset/version-gate
consistency; live third-party style URLs + GL JS major-bump probe).
37-assertion offline self-test, auto-discovered by run-skill-tests.sh and
wired into check-resources.sh (offline) + freshness.yml (live).

Stages the v3.1.0 release: plugin.json 3.0.0 -> 3.1.0, CHANGELOG [3.1.0]
section (adds mapbox-ops + pypi-ops), README Recent Updates v3.1.0 block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 1 month ago
parent
commit
d4af93397e

+ 1 - 1
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
   "name": "claude-mods",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "description": "Custom commands, skills, agents, rules, hooks, and output styles for Claude Code - session continuity and modern CLI tooling for real-world development workflows",
   "author": {
     "name": "0xDarkMatter"

+ 9 - 0
.github/workflows/freshness.yml

@@ -61,6 +61,15 @@ jobs:
           if [ "$rc" -eq 7 ]; then echo "::warning::ytdlp-ops live check unavailable (network/API/yt-dlp) — skipped"; fi
           exit 0
 
+      - name: mapbox-ops facts vs live (style URLs + GL JS major)
+        run: |
+          set +e
+          python skills/mapbox-ops/scripts/check-mapbox-facts.py --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::mapbox-ops drift — a third-party style URL 404'd or GL JS shipped a major past v3"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::mapbox-ops live check unreachable — skipped"; fi
+          exit 0
+
       - name: GitHub Action refs still resolve
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 15 - 0
CHANGELOG.md

@@ -7,7 +7,22 @@ feature releases live in the README "Recent Updates" section.
 
 ## [Unreleased]
 
+## [3.1.0] - 2026-06-17
+
 ### Added
+- **`mapbox-ops` skill** - advanced Mapbox GL JS toolkit for the web (v3, not the
+  native SDKs): custom markers, thematic dataviz, 3D/terrain, cinematic camera, style
+  composition, expressions, performance, and hard-won gotchas across 14 reference files.
+  Ships a headless-Playwright map verifier (`screenshot_map.py` - asserts a marker
+  projects to its lng/lat) and a stdlib-only `check-mapbox-facts.py` §7 staleness
+  verifier: offline asserts the v3 Standard config enums, terrain tileset IDs, and
+  weather/camera version gates stay internally consistent; live resolves the third-party
+  style URLs and flags a GL JS major bump past v3. 37-assertion offline self-test.
+- **`pypi-ops` skill** - publish Python packages to PyPI via OIDC Trusted Publishing
+  with PEP 740 attestations (`gh-action-pypi-publish`), not stored API tokens. Covers
+  first-publish pending-publisher setup, the invalid-publisher / already-exists failure
+  ladder, TestPyPI dry runs, release-environment approval gates, local `uv publish` /
+  `twine`, and a stale-OIDC-federation audit (the Mini Shai-Hulud publish-token vector).
 - **`docs/SKILL-CREATION-PROTOCOL.md`** - the canonical "how to build a claude-mods
   skill" sequencing doc (warranted? → frontmatter → body → resources → tests → repo
   wiring → ship). Cites rather than restates the layer-owning docs (skill-creator,

+ 8 - 0
README.md

@@ -22,6 +22,14 @@ From Python async patterns to Rust ownership models, from AWS Fargate deployment
 
 ## Recent Updates
 
+**v3.1.0** (June 2026)
+- 🗺️ **`mapbox-ops` skill** - advanced Mapbox GL JS for the web (v3): custom SVG/canvas markers and circular photo pins, thematic dataviz (choropleth, heatmaps, proportional symbols, 3D extrusions), terrain with hillshade and contours, cinematic flight/orbit camera and animated day–night cycles, style composition (v3 Standard slots + config, classic palette recolour, third-party styles), expression-driven styling, and the hard-won gotchas that silently drop your markers. 14 reference files plus a headless-Playwright marker-alignment verifier.
+- 📐 **Skill Creation Protocol** - [docs/SKILL-CREATION-PROTOCOL.md](docs/SKILL-CREATION-PROTOCOL.md), the canonical "how to build a claude-mods skill" doc: one sequenced lifecycle (warranted? → frontmatter → body → resources → tests → repo wiring → ship) that cites the layer-owning docs rather than restating them, with a precedence table for when they disagree.
+- 📋 **`adr-ops` skill** - Architecture Decision Records as a cross-project workflow. ADRs are append-only project memory: they capture *why* a system took its shape — the alternatives weighed, the constraints accepted — so a future maintainer recovers the reasoning without archaeology through git history or chat logs. Brings the when-to-write rule, canonical format, proposed→accepted→superseded lifecycle, and append-only supersession discipline, with five Resource-Protocol tools (init / new / index / `touches`-query / lint) and a 72-assertion suite.
+- 📚 **`okf-ops` skill** - assess, validate, and adopt the [Open Knowledge Format](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing/) — Google Cloud's vendor-neutral spec (v0.1, Apache-2.0) for packaging organizational knowledge as a directory of markdown files with YAML frontmatter that AI agents can query without a platform or SDK. A read-only readiness scanner finds good adoption candidates across many repos; a conformance validator (`--strict` for CI) checks a bundle. Honest scope baked in — OKF is a v0.1 draft, adopt per-repo.
+- 📦 **`pypi-ops` skill** - publish Python packages to PyPI the 2026 way: OIDC Trusted Publishing with PEP 740 attestations via `gh-action-pypi-publish`, not stored API tokens. First-publish pending-publisher setup, the `invalid-publisher` / "already exists" failure ladder, TestPyPI dry runs, release-environment approval gates, local `uv publish` / `twine`, and a stale-OIDC-federation audit.
+- 🔍 **github-ops auditor family** - a read-only repo-health suite: a security-posture auditor (Dependabot / secret + code scanning / private vuln reporting / SECURITY.md / branch protection, visibility-aware severity), open-issue surfacing wired into the pre-push gate as an advisory, and a scored `repo-scorecard` capstone that grades a repo — or an entire `--org` — in one pass, emitting fix commands but never applying them. The whole family now renders through the `term.sh` panel design system.
+
 **v3.0.0** (June 2026)
 - **Skills-first restructure** - *Breaking:* the expert-agent layer was cut from 23 to 3. Per Anthropic's guidance, knowledge belongs in skills (progressive disclosure, single source of truth) and subagents are reserved for context isolation — so *all* domain-knowledge agents became `-ops` skills (the 11 language/framework experts → their twins; cypress/cloudflare/bash/craftcms/payloadcms/asus-router → new skills; claude-architect/aws-fargate folded into existing skills). The 3 remaining agents are pure isolation/worker roles: `git-agent` (background commits/PRs), `firecrawl-expert` (noisy multi-page scrapes), `project-organizer` (bulk restructure). Dispatching skills now route `general-purpose` agents that preload skill references.
 - **`claude-code-ops` skill** - the machinery of Claude Code itself in one skill: the complete 30-event hook catalog with per-event stdin/stdout JSON contracts and all five hook types; the current `SKILL.md` frontmatter spec (`when_to_use`, `context: fork`, skill-scoped hooks); a headless/CLI reference (`claude -p`, `stream-json`, structured output, background agents); and extension-debugging decision trees. Merges and rebuilds the old claude-code-debug/-headless/-hooks skills against the live June-2026 docs - the stale `$TOOL_INPUT` hook contract is gone (stdin JSON is current), with claude-architect's extension-architecture guidance folded in.

+ 111 - 0
skills/mapbox-ops/SKILL.md

@@ -0,0 +1,111 @@
+---
+name: mapbox-ops
+description: >-
+  Advanced Mapbox GL JS toolkit for the WEB (mapbox-gl-js v3, not native/iOS):
+  build production map experiences — custom markers, thematic dataviz, 3D,
+  terrain, cinematic camera, style composition, performance, and hard-won
+  gotchas. Use for Mapbox GL JS work: custom markers, addImage / updateImage,
+  icon-image, SVG / canvas markers, icon-anchor / icon-offset, symbol layers,
+  geojson layers, clustering, feature-state hover /
+  select, queryRenderedFeatures, style expressions (interpolate / step / match /
+  case, zoom-outermost rule), line-dasharray, line casing, line-gradient,
+  hillshade, contours, setTerrain, raster-dem, fill-extrusion 3D buildings,
+  heatmap, choropleth / data-join, proportional symbols, basemap palette
+  recolour, setPaintProperty, v3 Standard style / slots / setConfigProperty /
+  lightPreset, style switcher / library, text labels / text-optional, popups,
+  flyTo / easeTo / freeCameraOptions camera animation, setStyle re-add,
+  map.resize, WebGL teardown, or headless Playwright map verification.
+license: MIT
+compatibility: "Web mapbox-gl-js v3.x. screenshot_map.py needs Python 3.10+ and Playwright (chromium); check-mapbox-facts.py is stdlib-only Python 3.10+."
+metadata:
+  author: claude-mods
+  related-skills: "color-ops, screenshot"
+---
+
+# Mapbox GL JS — advanced web toolkit (v3)
+
+An advanced toolkit for building production Mapbox GL JS map experiences on the **web**:
+markers, thematic dataviz, 3D, terrain, cinematic camera, style composition, performance,
+and the hard-won gotchas that bite. Scope: **`mapbox-gl-js` v3.x in the browser** (CDN
+`mapbox-gl-js/v3.x/`) — not the native iOS/Android SDKs (different APIs). Plain GL JS,
+framework-agnostic. Several patterns were distilled from a production trail map; adapt the
+constants to your own design.
+
+## Setup invariants
+
+- Set `mapboxgl.accessToken` before `new mapboxgl.Map(...)`.
+- The map needs **`'load'`** before adding sources/layers/images. In a throttled
+  or background tab `'load'` can be missed — also bind `'idle'` as a one-shot
+  fallback guarded by an `_inited` flag (see [verification.md](references/verification.md)).
+- Resolving token/style from `.env`: **read the token FIRST** (that triggers the
+  `.env` load), THEN read `MAPBOX_STYLE`. Reading the style before the token load
+  silently falls back to the default style. See [palette.md](references/palette.md).
+- **Classic vs Standard style.** Several techniques here (basemap palette recolour,
+  the terrain boost-or-add `getStyle().layers` walk) assume a **classic** style
+  (Streets/Outdoors/Light/Dark `…-v12`). The v3 default **Standard** style has no
+  enumerable named layers — use slots + `setConfigProperty` instead. See
+  [v3-standard-style.md](references/v3-standard-style.md) before porting to Standard.
+
+## Pick the technique
+
+Read the matching reference file only when the task needs it:
+
+| Task | Reference |
+|------|-----------|
+| Custom SVG/canvas markers, `addImage`/`updateImage`, namespacing, AA/fringing, circular image masks, anchoring | [references/markers.md](references/markers.md) |
+| Dashed/cased trail lines, `line-dasharray` units, translucency over hillshade, colour-by-attribute, `line-gradient`/`lineMetrics` | [references/lines-and-trails.md](references/lines-and-trails.md) |
+| Hillshade, dense contours, 3D terrain (`setTerrain`), boost-or-add an existing style's terrain | [references/terrain.md](references/terrain.md) |
+| Symbol-layer text labels that never hide icons (`text-optional`), AllTrails-style placement | [references/labels.md](references/labels.md) |
+| Recolour a base style's land/vegetation fills (palette shift / choropleth-style `match`) | [references/palette.md](references/palette.md) |
+| Custom popups, circular photo cards, zoom-scaled offsets | [references/popups.md](references/popups.md) |
+| Style expressions — `interpolate`/`step`/`match`/`case`, the **zoom-outermost** rule, `feature-state` in expressions | [references/expressions.md](references/expressions.md) |
+| Hover/select via `feature-state` (not `setData`), `queryRenderedFeatures` caveats, clustering, GeoJSON perf, event hygiene | [references/interaction-and-performance.md](references/interaction-and-performance.md) |
+| Data viz & 3D — `fill-extrusion` buildings/extruded data, heatmap layer, data-join choropleth (feature-state/`match`), proportional symbols, sky/fog | [references/dataviz-and-3d.md](references/dataviz-and-3d.md) |
+| Camera & animation — `flyTo`/`easeTo`/`fitBounds` padding, `freeCameraOptions` cinematics/orbit, flight/first-person camera (roll, 6-DoF), animated day–night cycle (`setLights`), HUD synced to camera, point-along-line, draw-in lines, paint transitions, spinning globe, the `essential`/reduced-motion gotcha | [references/camera-and-animation.md](references/camera-and-animation.md) |
+| Style library & composition — first-party style catalog, choosing a base by use case, custom/third-party styles, style switcher, light/dark, hand-rolled style JSON | [references/styles.md](references/styles.md) (+ [assets/style-catalog.json](assets/style-catalog.json)) |
+| `setStyle` wiping custom layers, the 0×0 `resize()` bug, SPA teardown / WebGL-context cap, token security, readiness events | [references/lifecycle.md](references/lifecycle.md) |
+| **v3 Standard style** — slots vs `beforeId`, `setConfigProperty`/`lightPreset`, why layer-walking (palette/terrain) breaks; localisation, RTL, globe | [references/v3-standard-style.md](references/v3-standard-style.md) |
+| Headless screenshot + pixel-accurate marker-alignment checks (Playwright, `map.project`) | [references/verification.md](references/verification.md) |
+
+## Bundled resources
+
+- **Starter code** — [assets/circular_image_marker.js](assets/circular_image_marker.js):
+  copy into a page to register a circular photo marker (canvas → premultiplied
+  `ImageBitmap`, `destination-in` mask, contact + drop shadow). Browser-only snippet,
+  not a CLI — adapt the `frameColor`/box constants to your design.
+- **Verifier script** — [scripts/screenshot_map.py](scripts/screenshot_map.py): drive
+  headless Chromium to screenshot a *served* map page, assert a marker projects to its
+  lng/lat, and surface console errors. Run it:
+
+  ```bash
+  python -m http.server 8777 --directory <site-dir> &          # serve the page
+  uv run --with playwright scripts/screenshot_map.py \
+    http://localhost:8777/preview/index.html out.png --expect 146.9 -36.1
+  # exit 0 = no console errors; 10 = errors found; 5 = playwright missing; 7 = map never ready
+  uv run --with playwright scripts/screenshot_map.py URL out.png --json | jq '.data'
+  ```
+
+- **Staleness verifier** — [scripts/check-mapbox-facts.py](scripts/check-mapbox-facts.py):
+  stdlib-only (no Playwright), guards the fast-moving facts this skill encodes
+  (SKILL-RESOURCE-PROTOCOL §7). `--offline` (default) asserts internal consistency —
+  the v3 Standard config enums (`lightPreset`/`theme`), terrain tileset IDs, the weather
+  (≥3.7) and camera-roll (≥3.5) version gates, and every style URL/id in
+  [assets/style-catalog.json](assets/style-catalog.json). `--live` resolves the
+  third-party style URLs and probes whether Mapbox GL JS has shipped a major past v3.
+
+  ```bash
+  python scripts/check-mapbox-facts.py --offline            # exit 0 ok, 4 inconsistency
+  python scripts/check-mapbox-facts.py --live --json        # exit 7 network, 10 drift
+  ```
+
+## The three highest-value gotchas (full detail in the refs)
+
+1. **Namespace every `addImage` name** (e.g. `"rcpin-<glyph>"`). Mapbox styles ship
+   sprite icons literally named `parking`/`toilet`/etc — an un-namespaced
+   `hasImage()` returns `true` for those and your icon is **silently dropped**.
+2. **Register icons as premultiplied `createImageBitmap()`**, not a raw
+   `HTMLImageElement`/`ImageData` — straight-alpha sources make Mapbox **fringe a
+   white halo** around anti-aliased edges. `updateImage(name, bmp)` recolours in place.
+3. **Data-driven `icon-offset` is silently ignored in GL JS v3.** Use a *constant*
+   `icon-offset` (it scales with `icon-size`) or split markers into separate symbol
+   layers, each with its own constant anchor/offset.

+ 0 - 0
skills/mapbox-ops/assets/.gitkeep


+ 100 - 0
skills/mapbox-ops/assets/circular_image_marker.js

@@ -0,0 +1,100 @@
+// Register a circular photo marker as a Mapbox GL JS map image.
+//
+// Why a canvas + ImageBitmap (not a DOM mapboxgl.Marker, not ctx.clip()):
+//   - addImage symbol layers batch/cluster/GPU-draw — DOM markers don't.
+//   - createImageBitmap() yields PREMULTIPLIED alpha → no white fringe at the AA edge
+//     (a raw HTMLImageElement/ImageData fringes a halo).
+//   - the disc is masked with destination-in (an anti-aliased arc FILL) on a scratch
+//     canvas, NOT ctx.clip() (clip is a 1-bit hard mask → jagged circle).
+//
+// Namespace the name ("rcphoto-…") so it can't collide with a basemap sprite icon
+// (an un-namespaced hasImage() can return true for the style's sprite and silently
+// drop your image).
+//
+// Same-origin: the photo must be same-origin (or CORS-enabled) or the canvas taints
+// and createImageBitmap throws.
+//
+// Usage:
+//   await addCircularPhotoMarker(map, "rcphoto-falls", "photos/thumbs/falls.jpg");
+//   map.addLayer({ id:"photo-pts", type:"symbol", source:"pts",
+//     layout:{ "icon-image":"rcphoto-falls", "icon-anchor":"bottom",
+//              "icon-offset":[0,8],            // tip on the point (8 units of pad below tip)
+//              "icon-size":["interpolate",["linear"],["zoom"], 11,0.82, 14,1.23, 17,1.5],
+//              "icon-allow-overlap":true } });
+
+function addCircularPhotoMarker(map, name, photoUrl, opts = {}) {
+  const {
+    frameColor = "#355e3b",   // ring colour
+    F = 6,                     // supersample factor (high DPI; never let icon-size > native)
+    boxW = 44, boxH = 52,      // logical box; tip at (boxW/2, 44), 8 units of pad below
+    cy = 21, rOut = 16.5, rPhoto = 13.1,
+  } = opts;
+
+  return new Promise((resolve) => {
+    if (map.hasImage(name)) return resolve(name);
+    const img = new Image();
+    img.crossOrigin = "anonymous";              // allow CORS-served images
+    img.onerror = () => resolve(null);
+    img.onload = async () => {
+      if (map.hasImage(name)) return resolve(name);
+      const W = boxW * F, H = boxH * F;
+      const c = document.createElement("canvas"); c.width = W; c.height = H;
+      const ctx = c.getContext("2d");
+      ctx.scale(F, F);                          // draw in logical (44×52) space
+      ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
+      const cx = boxW / 2, tipY = 44;
+
+      // (1) faint contact shadow under the tip so the bubble reads as grounded
+      ctx.save();
+      ctx.translate(cx, tipY); ctx.scale(1, 0.36);
+      const gnd = ctx.createRadialGradient(0, 0, 0, 0, 0, 13);
+      gnd.addColorStop(0, "rgba(20,16,9,0.28)"); gnd.addColorStop(1, "rgba(20,16,9,0)");
+      ctx.fillStyle = gnd; ctx.beginPath(); ctx.arc(0, 0, 13, 0, 2 * Math.PI); ctx.fill();
+      ctx.restore();
+
+      // bubble = circle + short downward spike to the tip (apex = the anchor point)
+      const bubble = (r) => {
+        const hb = r * 0.34;
+        ctx.beginPath(); ctx.moveTo(cx - hb, cy); ctx.lineTo(cx + hb, cy);
+        ctx.lineTo(cx, tipY); ctx.closePath(); ctx.fill();
+        ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.fill();
+      };
+
+      // (2) drop shadow on the frame body for lift (cleared before drawing the photo)
+      ctx.save();
+      ctx.shadowColor = "rgba(20,16,9,0.26)"; ctx.shadowBlur = 2.8; ctx.shadowOffsetY = 0.9;
+      ctx.fillStyle = frameColor; bubble(rOut);
+      ctx.restore();
+
+      // (3) subtle top-lit sheen on the frame (AA arc fill)
+      const sheen = ctx.createLinearGradient(0, cy - rOut, 0, cy + 2);
+      sheen.addColorStop(0, "rgba(255,255,255,0.26)"); sheen.addColorStop(1, "rgba(255,255,255,0)");
+      ctx.fillStyle = sheen; ctx.beginPath(); ctx.arc(cx, cy, rOut, 0, 2 * Math.PI); ctx.fill();
+
+      // (4) cover-fit + AA-mask the photo into the inner disc via destination-in
+      const pr = rPhoto * F;
+      const sc = document.createElement("canvas"); sc.width = sc.height = pr * 2;
+      const sx = sc.getContext("2d");
+      sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
+      const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1;
+      const scale = Math.max((pr * 2) / iw, (pr * 2) / ih);
+      sx.drawImage(img, pr - (iw * scale) / 2, pr - (ih * scale) / 2, iw * scale, ih * scale);
+      sx.globalCompositeOperation = "destination-in";        // AA circular mask (NOT clip())
+      sx.beginPath(); sx.arc(pr, pr, pr, 0, 2 * Math.PI); sx.fill();
+      ctx.drawImage(sc, cx - rPhoto, cy - rPhoto, rPhoto * 2, rPhoto * 2);
+
+      // (5) register premultiplied (createImageBitmap) so the edge doesn't fringe
+      try {
+        const bmp = await createImageBitmap(c);
+        if (!map.hasImage(name)) map.addImage(name, bmp, { pixelRatio: F });
+      } catch (e) {
+        // fallback: register the canvas directly (may fringe slightly)
+        if (!map.hasImage(name)) map.addImage(name, c, { pixelRatio: F });
+      }
+      resolve(name);
+    };
+    img.src = photoUrl;
+  });
+}
+
+if (typeof module !== "undefined" && module.exports) module.exports = { addCircularPhotoMarker };

+ 38 - 0
skills/mapbox-ops/assets/style-catalog.json

@@ -0,0 +1,38 @@
+{
+  "_comment": "Mapbox first-party base styles, current as of GL JS v3 (2026). Copy a `url` into `new mapboxgl.Map({style})` or `map.setStyle()`. `family:standard` styles are the v3 3D composited basemap (configure via setConfigProperty, insert layers via slot — see references/v3-standard-style.md). `family:classic` styles expose named, enumerable layers (palette recolour + layer-walking work — see references/palette.md, terrain.md). Verify the latest version suffixes against https://docs.mapbox.com/api/maps/styles/#mapbox-styles before relying on them.",
+  "styles": [
+    { "id": "standard",            "url": "mapbox://styles/mapbox/standard",                "family": "standard", "labels": true,  "imagery": false, "three_d": true,  "use": ["3d-city", "modern", "default", "configurable-light"] },
+    { "id": "standard-satellite",  "url": "mapbox://styles/mapbox/standard-satellite",      "family": "standard", "labels": true,  "imagery": true,  "three_d": true,  "use": ["3d-aerial", "satellite-with-3d"] },
+    { "id": "streets-v12",         "url": "mapbox://styles/mapbox/streets-v12",             "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["wayfinding", "general-reference", "busy"] },
+    { "id": "outdoors-v12",        "url": "mapbox://styles/mapbox/outdoors-v12",            "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["trail", "terrain", "hillshade", "outdoor"] },
+    { "id": "light-v11",           "url": "mapbox://styles/mapbox/light-v11",               "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["dataviz", "choropleth", "muted-base"] },
+    { "id": "dark-v11",            "url": "mapbox://styles/mapbox/dark-v11",                "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["dataviz", "dark-mode", "glowing-data"] },
+    { "id": "satellite-v9",        "url": "mapbox://styles/mapbox/satellite-v9",            "family": "classic",  "labels": false, "imagery": true,  "three_d": false, "use": ["pure-imagery", "no-labels"] },
+    { "id": "satellite-streets-v12","url": "mapbox://styles/mapbox/satellite-streets-v12",  "family": "classic",  "labels": true,  "imagery": true,  "three_d": false, "use": ["aerial-with-context", "photoreal"] },
+    { "id": "navigation-day-v1",   "url": "mapbox://styles/mapbox/navigation-day-v1",       "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["turn-by-turn", "routing-day"] },
+    { "id": "navigation-night-v1", "url": "mapbox://styles/mapbox/navigation-night-v1",     "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["turn-by-turn", "routing-night"] }
+  ],
+  "standard_presets": {
+    "_comment": "v3 Standard style (and standard-satellite) — set at runtime via map.setConfigProperty('basemap', key, value) on style.load. One style, many looks; custom layers preserved (no reload).",
+    "lightPreset": ["dawn", "day", "dusk", "night"],
+    "theme": ["default", "faded", "monochrome"]
+  },
+  "effects": {
+    "_comment": "Runtime, style-independent visual effects. Weather requires GL JS >= 3.7.",
+    "rain": "map.setRain({density, intensity, color, opacity}) | map.setRain(null)",
+    "snow": "map.setSnow({density, intensity, color, flakeSize}) | map.setSnow(null)",
+    "fog":  "map.setFog({range, color, 'high-color', 'horizon-blend'}) — globe/terrain atmosphere"
+  },
+  "third_party": [
+    { "id": "stadia-stamen-watercolor", "url": "https://tiles.stadiamaps.com/styles/stamen_watercolor.json", "looks": ["watercolor", "painterly", "artistic"], "key": "stadia (free tier)", "note": "Iconic hand-painted map. Stamen styles are served by Stadia." },
+    { "id": "stadia-stamen-toner",      "url": "https://tiles.stadiamaps.com/styles/stamen_toner.json",      "looks": ["high-contrast", "black-and-white", "editorial"], "key": "stadia (free tier)" },
+    { "id": "stadia-alidade-smooth-dark","url": "https://tiles.stadiamaps.com/styles/alidade_smooth_dark.json","looks": ["dark", "muted", "dataviz"], "key": "stadia (free tier)" },
+    { "id": "carto-positron",    "url": "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",    "looks": ["light", "minimal", "dataviz"], "key": "none (attribution)" },
+    { "id": "carto-dark-matter", "url": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", "looks": ["dark", "glowing-data", "dashboard"], "key": "none (attribution)" },
+    { "id": "carto-voyager",     "url": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",     "looks": ["balanced", "labelled", "dataviz"], "key": "none (attribution)" },
+    { "id": "openfreemap-liberty","url": "https://tiles.openfreemap.org/styles/liberty",                    "looks": ["streets", "osm-bright-like"], "key": "none" },
+    { "id": "thunderforest",     "looks": ["spinal-map (tron)", "atlas (vintage sepia)", "pioneer", "transport"], "key": "thunderforest (freemium)", "note": "style-JSON/raster via apikey; artistic catalog." },
+    { "id": "maptiler",          "looks": ["backdrop", "dataviz", "toner", "topo", "winter", "ocean", "bright"], "key": "maptiler (key)", "note": "https://api.maptiler.com/maps/<style>/style.json?key=…" },
+    { "id": "protomaps",         "looks": ["light", "dark", "white", "black", "grayscale", "contrast"], "key": "none (self-host PMTiles)", "note": "protomaps-themes-base; single-file basemap via pmtiles:// protocol." }
+  ]
+}

+ 189 - 0
skills/mapbox-ops/references/camera-and-animation.md

@@ -0,0 +1,189 @@
+# Camera & animation
+
+Camera control, cinematic fly-throughs, and animated data. How-to first, with the
+footguns that bite flagged inline.
+
+## Table of contents
+- [Camera methods](#camera-methods)
+- [fitBounds & cameraForBounds](#fitbounds--cameraforbounds)
+- [The `essential` / reduced-motion gotcha](#the-essential--reduced-motion-gotcha)
+- [freeCameraOptions — full 3D control](#freecameraoptions--full-3d-control)
+- [Animate a point along a line](#animate-a-point-along-a-line)
+- [Draw-in a line (progressive reveal)](#draw-in-a-line-progressive-reveal)
+- [Animated data & paint transitions](#animated-data--paint-transitions)
+- [Spinning globe](#spinning-globe)
+
+## Camera methods
+
+| Method | Use |
+|---|---|
+| `jumpTo(opts)` | Instant, no animation |
+| `easeTo(opts)` | Animated, constant-feel — short hops, pitch/bearing changes |
+| `flyTo(opts)` | Zoom-out-arc "flight" — best for long distances |
+| `panTo` / `panBy` / `rotateTo` / `snapToNorth` | Single-axis moves |
+
+All take `{center, zoom, bearing, pitch, padding, duration}`. `flyTo` adds the flight
+controls: **`curve`** (default 1.42 — higher = bigger zoom-out arc), `speed`,
+`screenSpeed`, `minZoom`, `maxDuration`.
+
+```js
+map.flyTo({ center:[146.9,-36.1], zoom:14, pitch:55, bearing:-20, curve:1.6, speed:0.8,
+            essential:true });
+```
+
+## fitBounds & cameraForBounds
+
+```js
+map.fitBounds(bounds, { padding:{top:40,bottom:40,left:320,right:40}, maxZoom:15,
+                        pitch: map.getPitch(), bearing: map.getBearing() });
+```
+
+- **`padding` as an object** is essential when a sidebar/legend/controls overlay the map
+  — otherwise the fitted content tucks *under* your UI. Per-edge padding offsets it clear.
+- Preserve a 3D camera across fits by passing the current `pitch`/`bearing` (a bare
+  `fitBounds` resets them to 0/north).
+- **`cameraForBounds(bounds, opts)`** returns the computed `{center, zoom, …}` *without
+  moving* — compute it, tweak it (nudge zoom, add pitch), then `easeTo` the result.
+
+## The `essential` / reduced-motion gotcha
+
+Any camera animation is **cancelled immediately** (jumps to the end) for users with
+`prefers-reduced-motion: reduce` — **unless you pass `{essential: true}`**. A "fly to the
+selected result" that mysteriously *jumps* for some users and animates for others is
+almost always this. Pass `essential:true` when the motion conveys meaning; otherwise
+honour the preference deliberately. (User scroll/drag also interrupts animations — usually
+desired; `essential` doesn't change that.)
+
+## freeCameraOptions — full 3D control
+
+When `flyTo`/`easeTo` can't express the shot (orbit a point, drone pass over terrain, a
+camera at a specific altitude looking at a target), drive the camera directly:
+
+```js
+const cam = map.getFreeCameraOptions();
+const target = [146.9, -36.1];
+// position the camera at an altitude, then aim it at the target
+cam.position = mapboxgl.MercatorCoordinate.fromLngLat([146.85, -36.15], 4000); // 4 km up
+cam.lookAtPoint(target);
+map.setFreeCameraOptions(cam);
+```
+
+- `MercatorCoordinate.fromLngLat([lng,lat], altitudeMetres)` — the 3rd arg is **metres**.
+- To move by real-world distance, scale with
+  `MercatorCoordinate.fromLngLat(c).meterInMercatorCoordinateUnits()`.
+- Orbit: in a RAF loop, advance an angle and recompute `position` on a circle around the
+  target, `lookAtPoint(target)` each frame.
+- With terrain on, the camera **collides** with the DEM (won't sink below ground); for low
+  passes set an altitude safely above the terrain.
+
+## Flight / first-person camera (drone, fly-through, sim)
+
+Continuous first-person movement — a flight sim, drone pass, or walkthrough — drives the
+camera every frame from a `{lng, lat, alt, heading, pitch, roll, speed}` state.
+
+- **Native (GL JS ≥ 3.5):** the camera now has **`roll`** alongside `bearing`/`pitch`
+  (`jumpTo`/`easeTo`/`flyTo({roll})`, `map.setRoll`) — enough for banking turns. Per-frame
+  `jumpTo` is instant (no easing lag):
+
+```js
+const s = { lng:146.9, lat:-36.1, alt:1500, heading:0, pitch:75, roll:0, speed:0 };
+function fly() {
+  // advance along heading by speed (deg/frame ≈ metres → deg)
+  const rad = s.heading * Math.PI/180, d = s.speed * 1e-5;
+  s.lat += Math.cos(rad) * d; s.lng += Math.sin(rad) * d;
+  map.jumpTo({ center:[s.lng,s.lat], bearing:s.heading, pitch:s.pitch, roll:s.roll });
+  requestAnimationFrame(fly);
+}
+fly();   // A/D → s.heading & s.roll, W/S → s.pitch, R/F → s.speed (key handlers)
+```
+
+- **Full 6-DoF** (arbitrary position + look vector, e.g. a nose-down dive that the
+  pitch-clamped map camera can't reach): use `freeCameraOptions` — set
+  `position = MercatorCoordinate.fromLngLat([lng,lat], alt)` and orientation each frame.
+- Pre-3.5, roll required `freeCameraOptions` (the map camera had only bearing/pitch).
+- Cap pitch near 85° on the native camera; beyond that switch to freeCamera or the horizon
+  inverts.
+
+## Animated day–night cycle
+
+- **Discrete (Standard style):** step `setConfigProperty("basemap","lightPreset", …)`
+  through `dawn→day→dusk→night` on a timer — snaps between four looks (cheap, no reload).
+- **Smooth (3D lights):** drive the v3 lights API and move the sun — animate the
+  directional light's direction by clock for a continuous sweep:
+
+```js
+map.setLights([
+  { id:"ambient", type:"ambient", properties:{ intensity:0.5 } },
+  { id:"sun", type:"directional",
+    properties:{ direction:[azimuthDeg, polarDeg], intensity:0.8, "cast-shadows":true } }
+]);
+// in a slow loop, advance azimuth/polar to sweep the sun → moving shadows, warm→cool light
+```
+
+## HUD synced to camera state
+
+A telemetry overlay (speed, heading, altitude) is absolutely-positioned DOM updated from
+the camera. Read state on `"move"`/`"render"` (not a free-running RAF) so it idles cheaply:
+
+```js
+map.on("render", () => {
+  hud.heading.textContent = Math.round(map.getBearing());
+  hud.pitch.textContent   = Math.round(map.getPitch());
+  hud.alt.textContent     = Math.round(map.getFreeCameraOptions().position.toAltitude());
+});
+```
+
+`getFreeCameraOptions().position.toAltitude()` is the live camera altitude in metres —
+the only way to read it (there's no `map.getAltitude()`).
+
+## Animate a point along a line
+
+`requestAnimationFrame` loop advancing a distance; get the point with Turf
+(`@turf/along`) or manual segment interpolation; `setData` a one-point source. Rotate an
+icon with `@turf/bearing`.
+
+```js
+let phase = 0, raf;
+const line = trail.features[0], total = turf.length(line);   // km
+function frame() {
+  phase = (phase + 0.02) % total;
+  const pt = turf.along(line, phase);
+  map.getSource("mover").setData(pt);
+  raf = requestAnimationFrame(frame);
+}
+frame();
+// cancelAnimationFrame(raf) on teardown — see lifecycle.md (leaked RAF loops survive map.remove)
+```
+
+## Draw-in a line (progressive reveal)
+
+Two options:
+- **Cheap:** one `line-gradient` over `["line-progress"]` and animate a single colour
+  stop from 0→1 (no geometry churn). Needs `lineMetrics:true` (see
+  [lines-and-trails.md](lines-and-trails.md)).
+- **Simple:** slice the coordinates up to `phase` and `setData` each frame (more work,
+  but lets you also drop a moving "head" marker).
+
+## Animated data & paint transitions
+
+**Paint** properties transition smoothly on change (layout properties do not). Set the
+transition then update the value:
+
+```js
+map.setPaintProperty("choro", "fill-color-transition", { duration: 600, delay: 0 });
+map.setPaintProperty("choro", "fill-color", nextRamp);   // animates over 600 ms
+```
+
+For continuous data updates, `setData` on a RAF/interval — but batch and throttle; every
+`setData` re-tiles the source (see [interaction-and-performance.md](interaction-and-performance.md)).
+
+## Spinning globe
+
+```js
+function spin() {
+  if (userInteracting || map.getZoom() > 5) return;
+  const c = map.getCenter(); c.lng -= 2;
+  map.easeTo({ center: c, duration: 1000, easing: t => t });   // linear, seamless loop
+}
+map.on("moveend", spin);   // chain each ease into the next; pause on user interaction
+```

+ 130 - 0
skills/mapbox-ops/references/dataviz-and-3d.md

@@ -0,0 +1,130 @@
+# Data viz & 3D
+
+Thematic mapping (choropleth, heatmap, proportional symbols) and extruded 3D. Colour-
+ramp *theory* (sequential/diverging, contrast, CVD) belongs to the related `color-ops`
+skill — this file is the **Mapbox wiring** and its footguns.
+
+## Table of contents
+- [fill-extrusion — 3D buildings & extruded data](#fill-extrusion--3d-buildings--extruded-data)
+- [Heatmap layer](#heatmap-layer)
+- [Data-join choropleth (your data isn't in the tiles)](#data-join-choropleth-your-data-isnt-in-the-tiles)
+- [Proportional symbols (√ scaling)](#proportional-symbols--scaling)
+- [Sky & atmosphere](#sky--atmosphere)
+
+## fill-extrusion — 3D buildings & extruded data
+
+Two uses: the basemap's buildings, or **your own** polygons (parcels, footprints, 3D
+bar/prism maps).
+
+```js
+// Basemap buildings (CLASSIC styles): composite source, source-layer "building".
+map.addLayer({ id:"3d-buildings", type:"fill-extrusion", source:"composite",
+  "source-layer":"building", filter:["==",["get","extrude"],"true"], minzoom:15,
+  paint:{
+    "fill-extrusion-color":"#ccc",
+    "fill-extrusion-height":["get","height"],          // metres, from the tile
+    "fill-extrusion-base":["get","min_height"],
+    "fill-extrusion-opacity":0.85,
+    "fill-extrusion-vertical-gradient":true }});
+```
+
+Footguns:
+- **Buildings only exist ~zoom 15+** and are **invisible without camera pitch** —
+  `map.easeTo({pitch:55})` or you'll swear nothing rendered.
+- Heights are **metres**. With terrain on, extrusions drape on the DEM (height is *above*
+  ground), which is usually what you want — but a `fill-extrusion-base` from sea-level
+  data will float/sink.
+- Extrusions render in a **single pass** — you can't interleave other layers between
+  faces by z-order; `fill-extrusion-opacity` < 1 looks wrong (faces show through). Keep
+  them near-opaque.
+- **v3 Standard** ships 3D buildings + landmarks built-in
+  (`setConfigProperty("basemap","show3dObjects",true)`), and has no `composite`/`building`
+  layer to target — custom `fill-extrusion` there is only for **your own** data. See
+  [v3-standard-style.md](v3-standard-style.md).
+
+Extruding your own data is the same layer with a geojson source and height from a
+property: `"fill-extrusion-height":["*",20,["get","stories"]]`.
+
+## Heatmap layer
+
+Four knobs, and they interact with zoom:
+
+```js
+map.addLayer({ id:"heat", type:"heatmap", source:"pts", maxzoom:15,
+  paint:{
+    // per-point contribution (data-driven weight)
+    "heatmap-weight":["interpolate",["linear"],["get","mag"], 0,0, 6,1],
+    // global multiplier — ramp UP with zoom so density stays readable
+    "heatmap-intensity":["interpolate",["linear"],["zoom"], 0,1, 15,3],
+    // radius in SCREEN px — ramp with zoom or it blobs/vanishes
+    "heatmap-radius":["interpolate",["linear"],["zoom"], 0,2, 15,20],
+    // colour ramp over density 0..1 — stop 0 MUST be transparent
+    "heatmap-color":["interpolate",["linear"],["heatmap-density"],
+      0,"rgba(0,0,255,0)", 0.2,"#80f", 0.5,"#f0f", 1,"#f00"],
+    "heatmap-opacity":["interpolate",["linear"],["zoom"], 13,1, 15,0] }});
+```
+
+Footguns:
+- **`heatmap-color` stop 0 must be `rgba(...,0)`** — a solid colour at density 0 washes
+  the entire canvas.
+- `heatmap-radius` is **screen pixels**, so apparent density shifts with zoom; ramp
+  `radius`/`intensity` across zoom and **fade to a `circle` layer** past `maxzoom` (the
+  `heatmap-opacity`→0 + a circle layer taking over at high zoom is the standard handoff).
+
+## Data-join choropleth (your data isn't in the tiles)
+
+Vector tiles carry geometry + a key (FIPS, postcode), **not your statistics**. Join at
+runtime, two ways:
+
+**1. `feature-state`** — best for interactive / changing data. Key on `promoteId`:
+
+```js
+map.addSource("counties", { type:"vector", url:"mapbox://…",
+  promoteId:{ "county": "FIPS" } });          // per-source-layer key → stable id
+map.addLayer({ id:"choro", type:"fill", source:"counties", "source-layer":"county",
+  paint:{ "fill-color":["interpolate",["linear"],
+    ["coalesce",["feature-state","rate"],0], 0,"#eee", 100,"#900"] }});
+
+function applyData(rows){                       // rows: {fips, rate}
+  for (const r of rows)
+    map.setFeatureState({source:"counties", sourceLayer:"county", id:r.fips}, {rate:r.rate});
+}
+// GOTCHA: feature-state is LOST when a tile reloads (pan/zoom). Reapply:
+map.on("sourcedata", e => { if (e.sourceId==="counties" && e.isSourceLoaded) applyData(DATA); });
+```
+
+**2. `match` expression** — fine for **static, small/medium** sets; bakes data into the
+style. A huge `match` (thousands of entries) bloats the style and re-evals on every
+frame — use feature-state past a few hundred features.
+
+```js
+"fill-color":["match",["get","FIPS"], "06001","#900", "06003","#c44", /* … */ "#eee"]
+```
+
+## Proportional symbols (√ scaling)
+
+Scale `circle-radius` by a value — but radius ∝ value makes big values **dwarf** the rest
+(area grows as r²). For honest area perception, radius ∝ **√value**:
+
+```js
+"circle-radius":["interpolate",["linear"],["zoom"],
+  10, ["*", 2, ["sqrt",["get","pop"]]],
+  16, ["*", 6, ["sqrt",["get","pop"]]] ]
+```
+
+## Sky & atmosphere
+
+For 3D/terrain/globe scenes, add depth cheaply:
+
+```js
+map.setFog({ range:[1,10], "horizon-blend":0.1, color:"#fff", "high-color":"#aaccff" });
+// optional sun: map.addLayer({ id:"sky", type:"sky",
+//   paint:{ "sky-type":"atmosphere", "sky-atmosphere-sun":[0,5] }});
+```
+
+`setFog` is what makes the globe and tilted terrain read as 3D rather than flat.
+
+**Weather (GL JS ≥ 3.7):** `map.setRain({density,intensity,color,opacity})` and
+`map.setSnow({density,intensity,flakeSize})` add animated precipitation over the 3D
+scene; pass `null` to clear. Dramatic with camera pitch + the Standard `night`/`dusk`
+`lightPreset` — see [styles.md](styles.md#visually-dynamic--artistic-styles).

+ 69 - 0
skills/mapbox-ops/references/expressions.md

@@ -0,0 +1,69 @@
+# Expressions
+
+Mapbox style expressions drive data- and zoom-dependent styling. The footguns below
+are the ones that cost real time.
+
+## The zoom-outermost rule (the big one)
+
+A **`["zoom"]`** expression may only appear as the **top-level input to an
+`interpolate` or `step`**. You **cannot nest it deeper** — not inside a `match`/`case`
+branch, not as an inner input to another expression, not in a `filter`. Violating it
+throws *"zoom expressions are not supported"* (or silently no-ops in some contexts).
+
+To vary by **both zoom and data**, put the zoom `interpolate` **outermost** and a data
+expression in each **stop output**:
+
+```js
+// WRONG — zoom nested inside match → error
+["match", ["get","kind"], "big", ["interpolate",["linear"],["zoom"], 10,2, 16,6], 1]
+
+// RIGHT — zoom outermost, data expression per stop
+["interpolate", ["linear"], ["zoom"],
+  10, ["match", ["get","kind"], "big", 2, 1],
+  16, ["match", ["get","kind"], "big", 6, 3]]
+```
+
+## `step` vs `interpolate`
+
+- **`interpolate`** — smooth blend between stops (sizes, opacities, colours, widths).
+  `["interpolate",["linear"],["zoom"], 11,3, 17,6]`. Use `["exponential",base]` for a
+  perceptually even ramp across many zooms.
+- **`step`** — hard jumps at thresholds (discrete tiers, e.g. cluster colour buckets).
+  `["step",["get","point_count"], "#0f0", 10,"#ff0", 100,"#f00"]`.
+
+## `match` vs `case`
+
+- **`match`** — switch on one input against literal values (fast, readable). Values can
+  be arrays to share an output. Last arg is the **mandatory default**.
+  `["match",["get","class"], ["wood","grass"],"#bcd29c", "#dde4d0"]`
+- **`case`** — ordered boolean conditions (ranges, compound logic). Last arg is the else.
+  `["case", ["<",["get","h"],100],"#0f0", ["<",["get","h"],500],"#ff0", "#f00"]`
+
+`match` only tests equality; the moment you need `<`/`>`/`&&`, switch to `case`.
+
+## `coalesce` for missing data
+
+Fields are often absent or typed inconsistently. Guard with `coalesce` (first non-null)
+and `to-string`/`to-number` to normalise:
+
+```js
+["match", ["to-string", ["coalesce", ["get","Difficulty"], ""]],
+  "1","#2e7d32", /* … */ "#cc7d45"]
+```
+
+## `feature-state` in expressions (hover/selection)
+
+`["feature-state","hover"]` reads runtime state set by `map.setFeatureState` — the
+canonical hover/highlight pattern (no `setData`, see
+[interaction-and-performance.md](interaction-and-performance.md)). Constraints:
+
+- **Paint properties only** — not layout, not `filter`.
+- The source feature needs a **stable id** (`generateId:true` or `promoteId`).
+- Default with `coalesce`, since state is undefined until first set:
+  `["case", ["boolean",["feature-state","hover"],false], "#ff0", "#888"]`
+
+## Debugging
+
+`map.queryRenderedFeatures(point)[0].layer.paint` and the GL JS console error messages
+name the offending sub-expression. Build complex expressions incrementally — Mapbox
+validates the whole tree and a single type mismatch rejects all of it.

+ 80 - 0
skills/mapbox-ops/references/interaction-and-performance.md

@@ -0,0 +1,80 @@
+# Interaction & performance
+
+## Hover/select via `feature-state`, NOT `setData`
+
+Re-running `source.setData(...)` to highlight a feature re-parses and re-tiles the whole
+collection every mouse-move — janky on anything but tiny data. Instead set **feature
+state** and read it in a paint expression (see [expressions.md](expressions.md)):
+
+```js
+map.addSource("pts", { type:"geojson", data: fc, generateId: true });  // stable ids
+map.addLayer({ id:"pts", type:"circle", source:"pts",
+  paint:{ "circle-color":
+    ["case",["boolean",["feature-state","hover"],false], "#ff0", "#3887be"] }});
+
+let hovered = null;
+map.on("mousemove", "pts", (e) => {
+  if (hovered !== null) map.setFeatureState({source:"pts", id:hovered}, {hover:false});
+  hovered = e.features[0].id;
+  map.setFeatureState({source:"pts", id:hovered}, {hover:true});
+});
+map.on("mouseleave", "pts", () => {
+  if (hovered !== null) map.setFeatureState({source:"pts", id:hovered}, {hover:false});
+  hovered = null;
+});
+```
+
+Ids: `generateId:true` assigns sequential ids; **`promoteId:"myKey"`** uses an existing
+property as the id (survives `setData`, unlike generated ids). Feature-state needs one.
+Vector-tile sources need `promoteId` keyed per source-layer.
+
+## `queryRenderedFeatures` caveats
+
+- Returns only features **currently rendered in the viewport** — nothing off-screen,
+  nothing in a hidden layer. Not a data query.
+- Returns **duplicates** for features spanning tile boundaries → **dedupe by id**.
+- Bare `map.queryRenderedFeatures(point)` hits every layer; pass `{layers:[...]}`.
+- For all loaded features regardless of viewport use `querySourceFeatures(source,
+  {sourceLayer})` — but it's unordered and may return tile-clipped fragments.
+
+## Clustering
+
+```js
+map.addSource("pois", { type:"geojson", data: fc, cluster:true,
+  clusterRadius:45, clusterMaxZoom:12,
+  // aggregate per cluster — sum/any/etc. over member features
+  clusterProperties:{ photos:["+",["case",["has","photo"],1,0]] } });
+```
+
+Click a cluster → expand to the zoom that breaks it up:
+
+```js
+map.on("click","clusters",(e)=>{
+  const f = map.queryRenderedFeatures(e.point,{layers:["clusters"]})[0];
+  map.getSource("pois").getClusterExpansionZoom(f.properties.cluster_id,(err,z)=>{
+    if(!err) map.easeTo({center:f.geometry.coordinates, zoom:z});
+  });
+});
+```
+
+Gotcha: **feature-state doesn't propagate to clustered children** — hover/select on the
+unclustered points layer, not the cluster circles.
+
+## GeoJSON source performance
+
+- Prefer **one source updated by setData** over many small sources; batch updates.
+- `tolerance` (default 0.375) simplifies geometry — raise it for dense lines you view
+  zoomed out; lower for crisp detail.
+- For very large static data, serve **vector tiles** (tippecanoe → `.mbtiles` →
+  hosted/`pmtiles`) instead of a megabyte GeoJSON; GL JS streams only visible tiles.
+- `buffer` and `lineMetrics:true` (needed for `line-gradient`) cost memory — enable only
+  when used.
+
+## Event hygiene
+
+- Layer-scoped `map.on("click", layerId, fn)` only fires for that layer's features;
+  map-wide `map.on("click", fn)` fires everywhere (use for "click empty map to deselect").
+- Pointer cursor: `mouseenter`/`mouseleave` per interactive layer toggling
+  `map.getCanvas().style.cursor`.
+- Remove listeners on teardown (see [lifecycle.md](lifecycle.md)); anonymous handlers
+  can't be removed — keep named refs if the layer is transient.

+ 45 - 0
skills/mapbox-ops/references/labels.md

@@ -0,0 +1,45 @@
+# Symbol-layer text labels
+
+## Labels that never hide the icon (AllTrails style)
+
+Put `text-field` on the **same symbol layer** as the icon, with `text-optional: true`
++ `text-allow-overlap: false`. The result: **icons always render**; labels auto-hide
+only where they would collide, and **reappear as you zoom in**. No data is lost.
+
+```js
+map.addLayer({ id:"poi-glyph", type:"symbol", source:"pois",
+  layout:{
+    "icon-image":["get","_iconimg"], "icon-anchor":"center", "icon-allow-overlap":true,
+    // label centred BELOW the marker:
+    "text-field":["coalesce",["get","_label"],""],
+    "text-size":11.5, "text-anchor":"top", "text-offset":[0,0.9],
+    "text-justify":"center", "text-max-width":8,
+    "text-optional":true,            // icon stays even if label is dropped
+    "text-allow-overlap":false       // labels declutter against each other
+  },
+  paint:{ "text-color":"#3f4035", "text-halo-color":"#fff", "text-halo-width":1.6 }});
+```
+
+Key combination:
+- `text-anchor:"top"` + `text-offset:[0, Y]` → label sits **centred below** the marker.
+- `text-optional:true` is what protects the icon — without it, a dropped label drops
+  the whole symbol (icon included).
+- `text-allow-overlap:false` (the default) lets crowded labels declutter.
+
+## Line labels (trail name along the path)
+
+```js
+map.addLayer({ id:"trail-label", type:"symbol", source:"trail",
+  layout:{ "symbol-placement":"line", "text-field":["coalesce",["get","Name"],""],
+           "text-size":12, "text-letter-spacing":0.02 },
+  paint:{ "text-color":"#753c1c", "text-halo-color":"#fff", "text-halo-width":2 }});
+```
+
+## Toggle labels on/off at runtime
+
+Set `text-field` to `""` to hide, restore the expression to show — no layer rebuild:
+
+```js
+map.setLayoutProperty("poi-glyph", "text-field",
+  showLabels ? ["coalesce",["get","_label"],""] : "");
+```

+ 64 - 0
skills/mapbox-ops/references/lifecycle.md

@@ -0,0 +1,64 @@
+# Lifecycle, resize, teardown, tokens
+
+## `setStyle` wipes your custom layers
+
+`map.setStyle(url)` replaces the **entire** style — every `addSource`/`addLayer`/
+`addImage` you made is gone. (The default `{diff:true}` only preserves things when
+diffing *within* a style; switching base styles is a full swap.) Re-install on the
+`style.load` event, driven by one idempotent installer you also call at init:
+
+```js
+function installCustom() {
+  if (!map.getSource("pts")) map.addSource("pts", {/* … */});
+  if (!map.getLayer("pts"))  map.addLayer({/* … */});
+  // re-register images too — they're cleared with the style
+}
+map.on("load", installCustom);
+map.on("style.load", installCustom);   // fires after every setStyle
+map.setStyle("mapbox://styles/mapbox/dark-v11");   // triggers style.load
+```
+
+Guard every re-add with `getSource`/`getLayer`/`hasImage` so the init path and the
+style-swap path share one function without double-add errors.
+
+## The 0×0 / half-rendered map → `map.resize()`
+
+A map initialised in a container that was `display:none`, in a collapsed flexbox cell, or
+inside a tab/accordion shown *after* init, renders at the wrong size (blank, clipped, or
+offset clicks). Call **`map.resize()`** once the container has real dimensions:
+
+```js
+new ResizeObserver(() => map.resize()).observe(document.getElementById("mapwrap"));
+// or after revealing a tab:  showTab(); map.resize();
+```
+
+## Teardown — `map.remove()` and the WebGL-context cap
+
+Browsers allow only **~16 live WebGL contexts**. An SPA that mounts a map on each route
+without disposing leaks contexts → eventually *"Too many active WebGL contexts"* and maps
+go blank. Always dispose:
+
+```js
+// React: useEffect cleanup. Vue: onBeforeUnmount. Plain: before removing the container.
+map.remove();   // frees the GL context, sources, workers, and listeners
+```
+
+`map.remove()` also drops all event handlers, so you don't need to unbind them first; but
+do clear external observers (the `ResizeObserver` above) and any `setInterval`/RAF loops.
+
+## Tokens — web security
+
+- Web pages use a **public token** (`pk.…`). **Restrict it by URL** in your Mapbox
+  account so a copied token can't be used off your domains.
+- **Never ship a secret token** (`sk.…`) in client code — it can create/delete tokens and
+  styles. Secret tokens are for server/build/upload only.
+- Rotating a leaked `pk.` token is a dashboard click; a leaked `sk.` is an incident.
+
+## Map readiness signals
+
+- `'load'` — style + first viewport loaded (add sources/layers after this). May be
+  missed in a background tab → also bind `'idle'` once (see
+  [verification.md](verification.md)).
+- `'idle'` — no more loading/rendering pending (good "everything settled" signal).
+- `'style.load'` — fires after each `setStyle`.
+- `map.loaded()` / `map.isStyleLoaded()` — synchronous checks for headless waits.

+ 90 - 0
skills/mapbox-ops/references/lines-and-trails.md

@@ -0,0 +1,90 @@
+# Lines & trails
+
+## `line-dasharray` units are line-widths
+
+The two numbers in `line-dasharray: [dash, gap]` are measured in **line-widths**, not
+pixels — so the pattern stays proportional as `line-width` changes. **Solid line =
+`[1, 0]`.**
+
+Gotcha: with `line-cap: "round"` each dash is extended by ~½ a line-width on **each
+end**, so neighbouring dashes **merge** and the gaps disappear. Fixes:
+
+- Widen the gap (e.g. `[1.1, 1.8]` instead of `[1.1, 0.6]`), or
+- Use `line-cap: "butt"` (square ends, no extension).
+
+```js
+map.setPaintProperty("trail-line", "line-dasharray", isDashed ? [1.1, 1.8] : [1, 0]);
+```
+
+## Casing + line split
+
+Draw two stacked line layers from the same source: a wider **casing** underneath and
+the coloured **line** on top. Use zoom-interpolated widths so the trail reads at every
+scale:
+
+```js
+const trailWidth  = s => ["interpolate",["linear"],["zoom"], 11,3*s, 14,4.5*s, 17,6*s];
+const casingWidth = s => ["interpolate",["linear"],["zoom"], 11,5*s, 14,7.5*s, 17,10*s];
+map.addLayer({ id:"trail-casing", type:"line", source:"trail",
+  layout:{"line-join":"round","line-cap":"round"},
+  paint:{"line-color":"#fff","line-opacity":0,"line-width":casingWidth(1)} });
+map.addLayer({ id:"trail-line", type:"line", source:"trail",
+  layout:{"line-join":"round","line-cap":"round"},
+  paint:{"line-color":ACCENT,"line-opacity":1,"line-width":trailWidth(1),
+         "line-dasharray":[1.1,1.8]} });
+```
+
+## Translucency over hillshade = lies-on-the-terrain look
+
+A **translucent** trail line over a hillshade layer lets the terrain shading **bleed
+through**, so the line reads as lying *on* the 3D relief rather than floating above
+it. Cheap and convincing — drop `line-opacity` toward ~0.7:
+
+```js
+map.setPaintProperty("trail-line", "line-opacity", 0.7);   // hillshade shades the line
+```
+
+(For the hillshade itself see [terrain.md](terrain.md).)
+
+## Colour by attribute (difficulty grading)
+
+Data-driven colour via a `match` expression. Coerce the field to string first — the
+same dataset often stores the grade as a number in one layer and a string in another:
+
+```js
+const DIFF_COLOR = ["match",["to-string",["coalesce",["get","Difficulty"],""]],
+  "1","#2e7d32", "2","#7cb342", "3","#f9a825", "4","#ef6c00", "5","#c62828",
+  /* fallback */ "#cc7d45"];
+map.setPaintProperty("trail-line", "line-color", DIFF_COLOR);
+```
+
+## `line-gradient` (needs `lineMetrics`)
+
+To colour a line *along its length* (elevation, speed, progress), use `line-gradient`
+over `["line-progress"]` (0→1). Two hard requirements or it **silently renders nothing**:
+
+- the **source** must set `lineMetrics: true` (not the layer — the source);
+- the layer must **not** also use `line-dasharray` — they're mutually exclusive.
+
+```js
+map.addSource("trail", { type:"geojson", data: fc, lineMetrics: true });   // REQUIRED
+map.addLayer({ id:"trail-grad", type:"line", source:"trail",
+  layout:{"line-cap":"round"},
+  paint:{ "line-width":5, "line-gradient":["interpolate",["linear"],["line-progress"],
+    0,"#2e7d32", 0.5,"#f9a825", 1,"#c62828"] }});
+```
+
+## Direction arrows along the line
+
+A repeating chevron icon placed along the line, auto-rotated to the line direction:
+
+```js
+map.addLayer({ id:"trail-arrows", type:"symbol", source:"trail",
+  layout:{ "symbol-placement":"line", "symbol-spacing":68, "icon-image":"rc-arrow",
+    "icon-size":["interpolate",["linear"],["zoom"], 12,0.32, 16,0.55],
+    "icon-rotation-alignment":"map", "icon-allow-overlap":true,
+    "icon-ignore-placement":true }});
+```
+
+Draw the chevron pointing **east** (0°); Mapbox rotates it to the segment bearing. Give
+it a dark halo stroke under a white stroke so it reads on any basemap.

+ 155 - 0
skills/mapbox-ops/references/markers.md

@@ -0,0 +1,155 @@
+# Custom markers (SVG / canvas → `addImage`)
+
+Two ways to put a custom marker on the map:
+
+- **`mapboxgl.Marker`** — a DOM element pinned to a lng/lat. Fine for a handful of
+  markers; doesn't cluster, doesn't batch, repaints in the DOM. Avoid for many points.
+- **Symbol layer + `map.addImage`** (this file) — register an image once, reference
+  it by name from a `symbol` layer's `icon-image`. Batches, clusters, GPU-drawn,
+  data-driven. Use this for anything beyond a few markers.
+
+## Table of contents
+- [Namespace every image name](#namespace-every-image-name) — silent-drop gotcha
+- [Register as premultiplied ImageBitmap](#register-as-premultiplied-imagebitmap) — white-fringe gotcha
+- [Recolour in place with updateImage](#recolour-in-place-with-updateimage)
+- [Circular image markers](#circular-image-markers) — mask correctly
+- [Atlas aliasing at low zoom](#atlas-aliasing-at-low-zoom) — keep edges chunky
+- [High pixelRatio, never upscale](#high-pixelratio-never-upscale)
+- [Anchoring: tip / centre / offset](#anchoring-tip--centre--offset) — data-driven offset is ignored
+
+## Namespace every image name
+
+Mapbox base styles ship sprite icons literally named `parking`, `toilet`, `circle`,
+etc. `map.hasImage("parking")` returns **`true`** for the style's sprite even though
+*you* never added it. If you guard `addImage` with that check, your icon is never
+registered and the symbol layer **silently falls back to the style's plain glyph**.
+
+Always prefix your names so they can't collide:
+
+```js
+const PIN = g => "rcpin-" + g;          // "rcpin-camera", "rcpin-parking", …
+if (!map.hasImage(PIN(glyph))) map.addImage(PIN(glyph), bmp, {pixelRatio: 4});
+```
+
+## Register as premultiplied ImageBitmap
+
+A marker drawn from an `<svg>`/`<canvas>` has **straight (non-premultiplied) alpha**
+at its anti-aliased edge. Feeding Mapbox a raw `HTMLImageElement` or `ImageData`
+makes it bleed a **white halo / fringe** around every curved edge.
+
+`createImageBitmap()` produces a **premultiplied-alpha** bitmap → clean edges. Fall
+back to the raw element only if `createImageBitmap` throws:
+
+```js
+const img = new Image(W, H);
+img.onload = async () => {
+  try {
+    const bmp = await createImageBitmap(img);   // premultiplied alpha → no fringe
+    if (map.hasImage(name)) map.updateImage(name, bmp);
+    else map.addImage(name, bmp, {pixelRatio});
+  } catch (e) {
+    if (!map.hasImage(name)) map.addImage(name, img, {pixelRatio});  // fallback
+  }
+};
+img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
+```
+
+For a `<canvas>` source, `createImageBitmap(canvas)` works the same way.
+
+## Recolour in place with updateImage
+
+To recolour a marker (e.g. a colour-picker in a dev panel), re-render the SVG/canvas
+with the new colour and call **`map.updateImage(name, bmp)`** — same name, no layer
+churn, the symbol layer repaints automatically. Rebuild *all* affected names:
+
+```js
+function setPoiColor(c) { poiColor = c; buildAllPins(map, /*force*/ true); }
+// addPin(): if hasImage → updateImage(name, bmp) else addImage(name, bmp, …)
+```
+
+## Circular image markers
+
+To crop a photo into a disc, **never use `ctx.clip()`** — clip is a 1-bit hard mask
+and leaves a **jagged** circle edge. Mask with an **anti-aliased arc fill** via
+`globalCompositeOperation = "destination-in"` on a scratch canvas:
+
+```js
+// scratch canvas sized to the photo disc
+const sc = document.createElement("canvas"); sc.width = sc.height = d;
+const sx = sc.getContext("2d");
+sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
+// cover-fit the photo
+const scale = Math.max(d/iw, d/ih);
+sx.drawImage(img, (d-iw*scale)/2, (d-ih*scale)/2, iw*scale, ih*scale);
+// AA circular mask: keep only pixels inside the arc
+sx.globalCompositeOperation = "destination-in";
+sx.beginPath(); sx.arc(d/2, d/2, d/2, 0, 2*Math.PI); sx.fill();
+// draw the masked disc into the marker canvas
+ctx.drawImage(sc, cx - r, cy - r, 2*r, 2*r);
+```
+
+Full marker (frame + contact shadow + drop shadow + sheen + masked photo) in
+[../assets/circular_image_marker.js](../assets/circular_image_marker.js) — copy-paste
+starter code, adapt the colour/box constants.
+
+Same-origin caveat: if the photo is fetched cross-origin the canvas is **tainted**
+and `createImageBitmap`/`toDataURL` throw. Serve the page and images from the same
+origin, or set `img.crossOrigin = "anonymous"` and serve images with CORS headers.
+
+## Atlas aliasing at low zoom
+
+Mapbox does **not mipmap** the icon atlas. A raster icon minified at low zoom
+**aliases/shimmers** — and thin, high-contrast features (a 1–2px white outline or
+photo rim) shimmer worst. Mitigations:
+
+- Keep small-marker edges **chunky**; drop thin white rings on small badges (we
+  removed a white pin-outline and a white photo-rim purely to stop the shimmer).
+- Fade markers in over zoom so the worst-aliased small sizes barely show:
+  `"icon-opacity": ["interpolate",["linear"],["zoom"], 11,0, 12,1]`.
+
+## High pixelRatio, never upscale
+
+Render the SVG/canvas at a **high DPI** (4×–6×) and register with the matching
+`{pixelRatio}` so the icon is crisp on retina. But **don't let `icon-size` scale the
+icon past its native pixel size** — upscaling a raster icon blurs it. Size the source
+big enough that your max `icon-size` still maps ≤ 1.0 of native.
+
+```js
+const F = 6, W = 44*F, H = 52*F;          // draw at 6×
+map.addImage(name, bmp, {pixelRatio: F}); // declare the 6×
+// icon-size ramp stays ≤ ~1.5 of the *logical* box, well within native pixels
+```
+
+## Anchoring: tip / centre / offset
+
+**Data-driven `icon-offset` is silently ignored in GL JS v3.** A `["get", …]` or
+`["case", …]` expression for `icon-offset` does nothing — no error, no offset. Two
+working approaches:
+
+1. **Constant `icon-offset`** — it's a layout constant that **scales with
+   `icon-size`**, so one value works across zooms. Pair it with matched padding baked
+   into every icon of that layer.
+2. **Split into separate symbol layers**, each with its own *constant* anchor/offset.
+   This is what to do when marker families need different anchors. Example: glyph
+   badges centred on the point vs photo bubbles whose tip sits on the point —
+
+```js
+// glyph badges: disc centred on the point
+map.addLayer({ id:"poi-glyph", type:"symbol", source:"pois",
+  filter:["all",["!",["has","point_count"]],["!=",["get","_kind"],"photo"]],
+  layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"center",
+           "icon-size":[/* zoom ramp */], "icon-allow-overlap":true }});
+
+// photo bubbles: padded tip lands on the point.
+// icon drawn in a 44×52 box with the tip at y=44 → 8 units of shadow padding below.
+// icon-anchor:"bottom" + constant icon-offset:[0,8] (× icon-size) cancels that pad.
+map.addLayer({ id:"poi-photo", type:"symbol", source:"pois",
+  filter:["all",["!",["has","point_count"]],["==",["get","_kind"],"photo"]],
+  layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"bottom",
+           "icon-offset":[0,8], "icon-size":[/* zoom ramp */],
+           "icon-allow-overlap":true }});
+```
+
+Rule of thumb for landing a padded marker's tip on the point: `icon-anchor:"bottom"`
++ `icon-offset:[0, padUnits]`, where `padUnits` is the shadow padding drawn below the
+tip (the offset is multiplied by `icon-size`, so it tracks zoom automatically).

+ 65 - 0
skills/mapbox-ops/references/palette.md

@@ -0,0 +1,65 @@
+# Recolouring a base style's palette
+
+Shift an off-the-shelf base style toward a custom palette at runtime with
+`setPaintProperty` on its land/vegetation fill layers — no Studio edit, no custom
+style upload. Touch only land & vegetation; leave water/roads/labels alone.
+
+> **Classic styles only.** This relies on named, enumerable layers (`land`, `landuse`,
+> …) which exist in Streets/Outdoors/Light/Dark but **not** in the v3 Standard style.
+> For Standard, use `setConfigProperty` — see [v3-standard-style.md](v3-standard-style.md).
+
+## The layer-type gotcha
+
+The colour property name depends on the layer **type**: a `background` layer uses
+`background-color`, a `fill` layer uses `fill-color`. Detect the type per layer:
+
+```js
+function setFill(id, val) {
+  const ly = map.getLayer(id);
+  if (!ly) return;                                   // layer name varies by style — guard
+  const prop = (ly.type === "background" ? "background" : "fill") + "-color";
+  try { map.setPaintProperty(id, prop, val); } catch (e) {}
+}
+```
+
+## Green-shift example (Mapbox Outdoors / Streets layer names)
+
+```js
+function setupPalette() {
+  setFill("land", "#e8ebe0");                                            // base land (background)
+  setFill("landcover-outdoors",                                          // vegetation
+    ["match",["get","class"], "snow","#ffffff", /* default */ "#c6d4ac"]);
+  setFill("national-park", "#aecb8e");
+  setFill("landuse", ["match",["get","class"],                          // per-class choropleth
+    "park","#bad49b", "pitch","#b2cd8c", "cemetery","#c4d8aa",
+    ["wood","grass","scrub"],"#bcd29c", "residential","#e9ece1",
+    /* default */ "#dde4d0"]);
+}
+```
+
+The `["match",["get","class"], …]` form is the general **choropleth** pattern: colour
+each feature by a categorical property, with a trailing default. The exact layer ids
+(`land`, `landuse`, `landcover-outdoors`, `national-park`) are Mapbox-style-specific —
+inspect `map.getStyle().layers` for the style in use and guard every `getLayer`.
+
+## Hiding the basemap's own POI labels
+
+A custom POI layer competes with the style's generic POI labels (its own car-park
+"P"s, etc.). Hide the base layer so only your markers show:
+
+```js
+if (map.getLayer("poi-label")) map.setLayoutProperty("poi-label", "visibility", "none");
+```
+
+## Resolving style + token from `.env` (order matters)
+
+When a token comes from a credential store / `.env`, **resolve the token first** —
+that read is what triggers the `.env` load — and only **then** read `MAPBOX_STYLE`. If
+you read the style before the token load runs, a style set only in `.env` is invisible
+and you silently fall back to the default style. (Python example, but the ordering rule
+is general.)
+
+```python
+token = resolve_token(cli_token)        # triggers .env load as a side effect
+style = cli_style or os.environ.get("MAPBOX_STYLE") or DEFAULT_STYLE   # now .env is loaded
+```

+ 59 - 0
skills/mapbox-ops/references/popups.md

@@ -0,0 +1,59 @@
+# Popups
+
+## Circular photo card needs `overflow: visible`
+
+A circular photo popup (a disc with its own ring + drop-shadow) gets **square-clipped**
+by the base `.mapboxgl-popup-content` rule, which sets `overflow: hidden`. That clips
+the circle's drop-shadow into hard square corners. Override it on a custom class, make
+the popup chrome transparent (the disc carries its own ring/shadow), and hide the tip:
+
+```css
+.pop-photo .mapboxgl-popup-content {
+  background: transparent; box-shadow: none; padding: 0; border-radius: 0;
+  overflow: visible;            /* essential — base rule clips the round shadow square */
+}
+.pop-photo .mapboxgl-popup-tip { display: none; }
+.pop-circle {
+  width: 228px; height: 228px; border-radius: 50%; overflow: hidden;
+  border: 6px solid #355e3b; box-shadow: 0 6px 22px rgba(0,0,0,.30);
+}
+.pop-circle img { width: 100%; height: 100%; object-fit: cover; display: block; }
+```
+
+```js
+new mapboxgl.Popup({ offset: photoPopupOffset(), closeButton:false,
+                     closeOnClick:true, className:"pop-photo", maxWidth:"none" })
+  .setLngLat(e.lngLat).setHTML(`<div class="pop-circle"><img src="${url}"></div>`).addTo(map);
+```
+
+## Scale the popup offset by icon-size
+
+If the marker scales with zoom, a fixed popup `offset` leaves a gap that's wrong at
+most zooms. Compute the offset from the current icon-size so the card **hugs** its
+marker at every zoom. `Popup` offset accepts a per-anchor object so the gap is right
+whether the card lands above, below, or beside the marker:
+
+```js
+function photoPopupOffset() {
+  const clear = Math.round(40 * photoIconSize(map.getZoom()) + 6);  // tip→top + small gap
+  return { bottom:[0,-clear], top:[0,14], left:[14,0], right:[-14,0] };
+}
+```
+
+Where `photoIconSize(z)` mirrors the symbol layer's `icon-size` zoom ramp (interpolate
+the same stops in JS), so the popup tracks the rendered marker exactly.
+
+## Dismissal & cursor niceties
+
+- `closeButton:false` + `closeOnClick:true` → no X button; click anywhere off the
+  popup to dismiss (hide the default close button via
+  `.mapboxgl-popup-close-button{display:none}` if a stray one appears).
+- Pointer cursor on interactive layers:
+  ```js
+  for (const lyr of ["clusters","poi-glyph","poi-photo"]) {
+    map.on("mouseenter", lyr, () => map.getCanvas().style.cursor = "pointer");
+    map.on("mouseleave", lyr, () => map.getCanvas().style.cursor = "");
+  }
+  ```
+- Animate only opacity (`@keyframes` on `.mapboxgl-popup`) — Mapbox owns the
+  positioning `transform`, so don't animate transform or the popup jumps.

+ 187 - 0
skills/mapbox-ops/references/styles.md

@@ -0,0 +1,187 @@
+# Style library & composition
+
+Compose maps in a variety of looks: pick a base style by use case, switch styles at
+runtime, mix in third-party tiles, or hand-roll a style. The machine-readable catalog is
+[../assets/style-catalog.json](../assets/style-catalog.json) — load it to build a style
+switcher or to look up a `url` by `use` tag.
+
+## Table of contents
+- [First-party style catalog](#first-party-style-catalog)
+- [Choose a base by use case](#choose-a-base-by-use-case)
+- [Setting & switching styles at runtime](#setting--switching-styles-at-runtime)
+- [Light/dark by system preference](#lightdark-by-system-preference)
+- [Custom & third-party styles](#custom--third-party-styles)
+- [Hand-rolled style JSON](#hand-rolled-style-json)
+- [Gotchas](#gotchas)
+
+## First-party style catalog
+
+| Style | URL | Family | Best for |
+|---|---|---|---|
+| Standard | `mapbox://styles/mapbox/standard` | standard (3D) | v3 default; 3D city, modern, configurable light |
+| Standard Satellite | `mapbox://styles/mapbox/standard-satellite` | standard (3D) | 3D + aerial |
+| Streets | `mapbox://styles/mapbox/streets-v12` | classic | wayfinding, general reference |
+| Outdoors | `mapbox://styles/mapbox/outdoors-v12` | classic | trail/terrain/hillshade |
+| Light | `mapbox://styles/mapbox/light-v11` | classic | **dataviz** base (muted) |
+| Dark | `mapbox://styles/mapbox/dark-v11` | classic | **dataviz** base (dark) |
+| Satellite | `mapbox://styles/mapbox/satellite-v9` | classic | pure imagery (no labels) |
+| Satellite Streets | `mapbox://styles/mapbox/satellite-streets-v12` | classic | aerial + labels |
+| Navigation Day/Night | `mapbox://styles/mapbox/navigation-{day,night}-v1` | classic | turn-by-turn |
+
+`family` matters: **classic** styles have named, enumerable layers (palette recolour +
+the terrain layer-walk work); **standard** is the v3 composited 3D basemap (use slots +
+`setConfigProperty`). See [v3-standard-style.md](v3-standard-style.md). Version suffixes
+(`-v11`/`-v12`) move — confirm against the Mapbox Styles API before pinning.
+
+## Choose a base by use case
+
+- **Choropleth / heatmap / any thematic data** → `light-v11` or `dark-v11`. A muted base
+  lets the data carry the colour; `streets` competes with it. (Or Standard with a
+  monochrome `lightPreset`.)
+- **Trail / outdoor / elevation** → `outdoors-v12` (ships contours + terrain hooks).
+- **Photoreal / inspection** → `satellite-streets-v12` (plain `satellite-v9` has no labels
+  or roads — context-free imagery).
+- **3D city / showcase** → `standard` with `lightPreset:"dusk"`/`"night"`, or
+  `standard-satellite`.
+- **Wayfinding / routing** → `streets-v12` or `navigation-*`.
+
+## Setting & switching styles at runtime
+
+Set at init with `style:`; switch with `setStyle()`. **Switching wipes your custom
+sources/layers/images** — re-add them on `style.load` via one idempotent installer (full
+pattern in [lifecycle.md](lifecycle.md)):
+
+```js
+const map = new mapboxgl.Map({ container:"map", style:"mapbox://styles/mapbox/light-v11" });
+function installCustom(){ /* guarded addSource/addLayer/addImage */ }
+map.on("load", installCustom);
+map.on("style.load", installCustom);   // fires after every setStyle
+
+document.querySelector("#styleSwitcher").addEventListener("change", e =>
+  map.setStyle(e.target.value));       // installCustom re-runs on style.load
+```
+
+A switcher UI can be generated straight from the catalog asset (map each entry to an
+`<option value="<url>">`).
+
+## Light/dark by system preference
+
+```js
+const dark = matchMedia("(prefers-color-scheme: dark)");
+const pick = () => dark.matches ? "mapbox://styles/mapbox/dark-v11"
+                                : "mapbox://styles/mapbox/light-v11";
+map.setStyle(pick());
+dark.addEventListener("change", () => map.setStyle(pick()));
+// On the Standard style, prefer one style + setConfigProperty('basemap','lightPreset',
+// dark.matches ? 'night' : 'day') — no full reload, keeps custom layers (no re-add).
+```
+
+The Standard `lightPreset` route is cheaper and avoids the re-add dance — switch full
+styles only when crossing the classic↔standard or imagery↔vector boundary.
+
+## Custom & third-party styles
+
+- **Mapbox Studio**: design → publish → use `mapbox://styles/USERNAME/STYLEID`. This is
+  the right home for brand palettes, custom fonts/sprites, and curated layer visibility.
+- **Third-party vector tiles** (no Mapbox basemap): pass a full **style-JSON URL** to
+  `style:` — MapTiler, Stadia, OpenFreeMap, or Protomaps/PMTiles. These use their **own**
+  API key, not a Mapbox token.
+- **Mixing**: Mapbox-hosted tilesets used elsewhere in this skill
+  (`mapbox.mapbox-terrain-dem-v1`, `mapbox-terrain-v2`) require a **Mapbox** token even on
+  a third-party base style — so a fully token-free OSM base can't pull Mapbox DEM/contours.
+  Source equivalents (e.g. AWS Terrain-RGB DEM, Terrarium tiles) exist if you must stay
+  off Mapbox.
+
+## Visually dynamic & artistic styles
+
+### Standard presets — free, it's the default style
+
+One style, many distinct looks via `setConfigProperty("basemap", …)` (no reload, custom
+layers preserved):
+
+- **`lightPreset`** — `"dawn"|"day"|"dusk"|"night"`. `night` lights building windows and
+  streetlights; `dusk`/`dawn` cast long shadows. Pair with camera pitch for drama.
+- **`theme`** — `"default"|"faded"|"monochrome"`. `monochrome` is a single-hue minimalist
+  base (excellent under dataviz); `faded` desaturates so overlays pop.
+- Mix `theme` × `lightPreset` for a wide range from a single style.
+
+```js
+map.on("style.load", () => {
+  map.setConfigProperty("basemap", "lightPreset", "dusk");
+  map.setConfigProperty("basemap", "theme", "monochrome");
+});
+```
+
+### Weather & atmosphere effects (GL JS ≥ 3.7)
+
+Animated, genuinely dynamic — particles over the 3D scene:
+
+```js
+map.setRain({ density:0.5, intensity:1.0, color:"#a8adbc", opacity:0.7 });
+map.setSnow({ density:0.85, intensity:1.0, flakeSize:0.71 });
+map.setRain(null); map.setSnow(null);     // clear
+```
+
+Best with camera pitch + Standard `night`/`dusk`. Fog/haze (`setFog`) for globe/terrain
+depth lives in [dataviz-and-3d.md](dataviz-and-3d.md#sky--atmosphere). All three are
+runtime calls, independent of the chosen style.
+
+### Third-party artistic basemaps
+
+Pass these as **style-JSON URLs** to `style:` / `setStyle()`. They use their **own** key +
+attribution (not a Mapbox token) and can't pull Mapbox-hosted DEM/contours
+([see Mixing](#custom--third-party-styles)).
+
+| Source | Standout looks | Key? |
+|---|---|---|
+| **Stadia / Stamen** | **Watercolor** (hand-painted), **Toner** (high-contrast B&W), Terrain | key (free tier) |
+| **CARTO** | Positron, **Dark Matter**, Voyager — clean dataviz classics | no key (attribution) |
+| **Thunderforest** | **Spinal Map** (Tron-like), **Atlas** (vintage sepia), Pioneer, Transport | key (freemium) |
+| **Protomaps** | light/dark/white/black/grayscale/**contrast**; single-file PMTiles | no key (self-host) |
+| **OpenFreeMap** | Liberty, Bright, Positron | no key |
+| **MapTiler** | Backdrop, Dataviz, Toner, Topo, **Winter**, **Ocean**, Bright | key |
+
+Stamen **Watercolor** and Thunderforest **Atlas** are the showstoppers for a painterly /
+vintage look; CARTO **Dark Matter** + glowing data is the canonical "dashboard at night".
+Exact style URLs live in [../assets/style-catalog.json](../assets/style-catalog.json).
+
+### Roll your own dramatic look (e.g. synthwave / neon)
+
+For a bespoke neon/synthwave style (à la a night flight-sim), start from a near-black
+base and lean on glow: `line-color` in magenta/cyan with a wide, low-opacity `line-blur`
+casing under a bright thin line; `fill-extrusion` buildings in a dark hue with a neon
+edge; `setFog` with a saturated `high-color`. Build it in Studio (publish → `mapbox://`)
+or hand-roll the style JSON (next section). This composes with camera pitch + a `night`
+`lightPreset` for the full effect.
+
+## Hand-rolled style JSON
+
+A style is just JSON (spec **version 8**): `sources`, `layers`, plus `sprite`, `glyphs`,
+and optional `light`/`fog`/`terrain`/`projection`. Build a bespoke base by passing an
+object instead of a URL:
+
+```js
+const map = new mapboxgl.Map({ container:"map", style:{
+  version: 8,
+  glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",   // REQUIRED for any text label
+  sources: { osm: { type:"raster", tiles:["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize:256 } },
+  layers: [
+    { id:"bg", type:"background", paint:{ "background-color":"#e8ebe0" } },
+    { id:"osm", type:"raster", source:"osm" }
+  ]
+}});
+```
+
+To tweak an existing base instead of starting blank: fetch its style JSON
+(`https://api.mapbox.com/styles/v1/mapbox/light-v11?access_token=…`), edit, and pass the
+object.
+
+## Gotchas
+
+- **`version: 8`** is mandatory in a hand-rolled style; anything else fails to load.
+- **No `glyphs` URL → no text labels** render (silent). **No `sprite` → no icon-image**
+  sprite icons.
+- `satellite-v9` is imagery only — no labels/roads; use `satellite-streets` for context.
+- Switching styles loses custom layers — always re-add on `style.load`
+  ([lifecycle.md](lifecycle.md)).
+- classic vs standard changes which techniques apply ([v3-standard-style.md](v3-standard-style.md)).

+ 95 - 0
skills/mapbox-ops/references/terrain.md

@@ -0,0 +1,95 @@
+# Terrain: hillshade, contours, 3D
+
+All three use Mapbox-hosted tilesets available to **any** access token — no special
+entitlement needed.
+
+## Hillshade (shaded relief)
+
+A `raster-dem` source + a `hillshade` layer. **`hillshade-exaggeration` max is 1.0.**
+
+```js
+map.addSource("rc-dem", { type:"raster-dem",
+  url:"mapbox://mapbox.mapbox-terrain-dem-v1", tileSize:512, maxzoom:14 });
+map.addLayer({ id:"rc-hillshade", type:"hillshade", source:"rc-dem",
+  paint:{ "hillshade-exaggeration":1.0,            // 1.0 = max
+          "hillshade-shadow-color":"#4a3f30",
+          "hillshade-highlight-color":"#faf6ec",
+          "hillshade-accent-color":"#6e5b42" }}, firstSymbolLayerId);
+```
+
+## Contours (altitude lines)
+
+A **vector** source `mapbox://mapbox.mapbox-terrain-v2`, source-layer **`"contour"`** —
+far denser than the sparse contours most base styles ship (down to ~10 m where
+available). The `index` field flags the **index lines** (every 5th/10th) for heavier
+styling. Draw a fine base set + a bolder filtered index set:
+
+```js
+map.addSource("rc-terrain", { type:"vector", url:"mapbox://mapbox.mapbox-terrain-v2" });
+map.addLayer({ id:"rc-contour", type:"line", source:"rc-terrain", "source-layer":"contour",
+  layout:{"line-join":"round"},
+  paint:{ "line-color":"#7a5733",
+    "line-width":["interpolate",["linear"],["zoom"], 12,0.4, 16,0.85],
+    "line-opacity":["interpolate",["linear"],["zoom"], 11,0.20, 14,0.34, 17,0.44] }},
+  firstSymbolLayerId);
+map.addLayer({ id:"rc-contour-index", type:"line", source:"rc-terrain", "source-layer":"contour",
+  filter:[">=",["coalesce",["get","index"],0],5],   // index (every 5th/10th) lines, bolder
+  layout:{"line-join":"round"},
+  paint:{ "line-color":"#5e3f1d",
+    "line-width":["interpolate",["linear"],["zoom"], 12,0.9, 16,1.7],
+    "line-opacity":["interpolate",["linear"],["zoom"], 11,0.30, 14,0.48, 17,0.58] }},
+  firstSymbolLayerId);
+```
+
+## 3D terrain (extruded DEM + tilted camera)
+
+`map.setTerrain` extrudes the DEM; tilt the camera and let the user rotate it:
+
+```js
+map.addControl(new mapboxgl.NavigationControl({ visualizePitch:true }), "bottom-right");
+// enable
+map.setTerrain({ source:"rc-dem", exaggeration:1.4 });
+map.easeTo({ pitch:64, duration:900 });
+// disable
+map.setTerrain(null);
+map.easeTo({ pitch:0, bearing:0, duration:700 });
+```
+
+`visualizePitch:true` shows the pitch on the compass. Right-drag / ctrl-drag rotates &
+pitches the camera. When re-fitting bounds after enabling 3D, preserve the camera:
+`map.fitBounds(b, { pitch: map.getPitch(), bearing: map.getBearing() })`.
+
+## Boost-or-add pattern (respect the base style)
+
+> **Classic styles only.** The `getStyle().layers` walk below finds named layers that
+> exist in classic styles but **not** in the v3 Standard style (which exposes no
+> enumerable basemap layers). On Standard, add your DEM/contour layers via a `slot` and
+> skip the boost step — see [v3-standard-style.md](v3-standard-style.md).
+
+Don't blindly add tilesets — the chosen style may already ship hillshade/contours.
+Walk `map.getStyle().layers`: **boost** an existing hillshade (match by `type`) or
+contour (match by id regex); **add** the Mapbox tilesets only if absent. Insert added
+layers **beneath the first `symbol` layer** so labels stay on top.
+
+```js
+function setupTerrain() {
+  const layers = map.getStyle().layers || [];
+  const firstSymbol = (layers.find(l => l.type === "symbol") || {}).id;  // sit beneath labels
+  let hasHill = false;
+  for (const ly of layers) {
+    if (ly.type === "hillshade") {                 // boost the style's own hillshade
+      hasHill = true;
+      map.setPaintProperty(ly.id, "hillshade-exaggeration", 1.0);
+      map.setPaintProperty(ly.id, "hillshade-shadow-color", "#4a3f30");
+    }
+    if (/contour/i.test(ly.id) && ly.type === "line") {   // hide its sparse contour lines
+      map.setLayoutProperty(ly.id, "visibility", "none"); // (keep contour LABELS — the numbers)
+    }
+  }
+  if (!hasHill) { /* addSource rc-dem + addLayer rc-hillshade above, before firstSymbol */ }
+  /* always add the dense Terrain-v2 contours above, before firstSymbol */
+}
+```
+
+Track every layer id you boosted or added in one array so a single "Terrain" toggle
+can flip them all with `setLayoutProperty(id, "visibility", …)`.

+ 64 - 0
skills/mapbox-ops/references/v3-standard-style.md

@@ -0,0 +1,64 @@
+# v3 Standard style — slots, config, and why layer-walking breaks
+
+GL JS v3 introduced the **Standard** style (`mapbox://styles/mapbox/standard`) — a 3D,
+composited basemap that is now the default in Mapbox Studio. It behaves **differently
+from the classic styles** (Streets/Outdoors/Light/Dark, `…-v11`/`-v12`), and several
+techniques in this skill assume classic styles. Read this before applying them to
+Standard.
+
+## Classic vs Standard — which am I on?
+
+| | Classic (`outdoors-v12`, `streets-v12`, …) | Standard (`standard`, `standard-satellite`) |
+|---|---|---|
+| Layers | Hundreds of **named, enumerable** layers (`land`, `landuse`, `contour`, `poi-label`) | A **single composited basemap** — `getStyle().layers` shows only a few opaque entries |
+| Insert position | `addLayer(layer, beforeId)` | `addLayer({…, slot})` — **`"bottom"` / `"middle"` / `"top"`** |
+| Recolour basemap | `setPaintProperty` on named fill layers | **`setConfigProperty("basemap", …)`** — not arbitrary recolour |
+| Light / time of day | n/a | `setConfigProperty("basemap","lightPreset","dawn\|day\|dusk\|night")` |
+| 3D buildings/terrain | add yourself | built in |
+
+## What this changes in this skill
+
+- **[palette.md](palette.md)** (recolouring `land`/`landuse`/`landcover-outdoors`) and
+  the **boost-or-add `getStyle().layers` walk** in **[terrain.md](terrain.md)** assume
+  **classic** styles. On Standard those layer ids don't exist, so the walk finds nothing
+  and the `setPaintProperty` calls no-op. The trail-map source uses
+  `outdoors-v12` (classic), so that code is correct **there** — just don't port it to a
+  Standard-style page unchanged.
+- For a green/verdant look on **Standard**, you can't recolour vegetation per-class; pick
+  a `lightPreset`, or use a classic style / a custom Studio style instead.
+
+## Inserting your layers with `slot`
+
+On Standard, place custom layers in a slot so the basemap's labels/roads stay correctly
+above/below them:
+
+```js
+map.addLayer({ id:"trail-line", type:"line", source:"trail", slot:"middle",
+  paint:{ /* … */ } });   // "bottom" under everything, "top" above roads/labels
+```
+
+`slot` and `beforeId` are mutually exclusive. A `beforeId` referencing a classic layer id
+silently fails on Standard — use `slot`.
+
+## Configuring the basemap
+
+```js
+map.on("style.load", () => {
+  map.setConfigProperty("basemap", "lightPreset", "dusk");
+  map.setConfigProperty("basemap", "showPointOfInterestLabels", false);  // hide POI labels
+  map.setConfigProperty("basemap", "show3dObjects", true);
+});
+```
+
+(On classic styles you'd instead `setLayoutProperty("poi-label","visibility","none")` —
+see [palette.md](palette.md).) Available config keys vary by style; read them from the
+style's `schema`/imports or the Mapbox Standard docs.
+
+## Other v3 niceties
+
+- **Localisation**: `map.setLanguage("fr")` / `map.setWorldview("CN")` (v3, all styles).
+- **RTL text**: `mapboxgl.setRTLTextPlugin(url, null, true)` once, before adding Arabic/
+  Hebrew labels, or they render left-to-right and disjointed.
+- **Globe**: v3 defaults to `projection:"globe"` at low zoom; set
+  `map.setProjection("mercator")` if a flat map is required (e.g. for pixel-exact
+  `project()` overlays).

+ 55 - 0
skills/mapbox-ops/references/verification.md

@@ -0,0 +1,55 @@
+# Headless verification
+
+Verify a Mapbox page renders and markers land where expected by driving headless
+Chromium with Playwright and screenshotting. Pixel-accurate alignment checks use
+`map.project(lngLat)`.
+
+## Serve, don't `file://`
+
+A page that `fetch`es GeoJSON/photos at runtime must be **served** (same-origin, or the
+canvas taints and `createImageBitmap` throws — see [markers.md](markers.md)):
+
+```bash
+python -m http.server 8777 --directory <site-dir>
+# open http://localhost:8777/preview/index.html
+```
+
+## The `'load'`-in-background-tab quirk
+
+Mapbox's `'load'` event **may not fire** in a throttled / backgrounded / headless tab —
+a known harness quirk; the map renders fine in the foreground. Guard your init so
+either event triggers it exactly once:
+
+```js
+let _inited = false;
+function init() { if (_inited) return; _inited = true; /* setup layers, render */ }
+map.on("load", init);
+map.on("idle", init);   // fallback if the first 'load' frame was missed
+```
+
+In Playwright, wait on a DOM/JS signal you control rather than the map's `'load'`:
+e.g. set `window.__mapReady = true` at the end of `init()` and
+`page.wait_for_function("window.__mapReady === true")`.
+
+## Marker-alignment check via `map.project`
+
+`map.project([lng, lat])` returns the pixel coords **relative to the map canvas**. Add
+the canvas's page offset to compare against a screenshot or a DOM-space click:
+
+```js
+const m = map.getCanvas().getBoundingClientRect();
+const p = map.project([lng, lat]);
+const pageXY = { x: m.left + p.x, y: m.top + p.y };   // where the marker's anchor lands
+```
+
+The runnable harness — launch chromium, wait for ready, screenshot, and assert a
+known lng/lat projects to the expected pixel — is in
+[../scripts/screenshot_map.py](../scripts/screenshot_map.py).
+
+## What to assert
+
+- No console errors (`page.on("console", …)`), especially `Image "<name>" already
+  exists` (double-`addImage`) or style-load failures.
+- The canvas isn't blank (screenshot non-uniform, or `map.loaded() === true`).
+- A representative marker's projected pixel falls within the canvas and (optionally)
+  over a non-background colour in the screenshot.

+ 337 - 0
skills/mapbox-ops/scripts/check-mapbox-facts.py

@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+# Staleness verifier for the fast-moving facts the mapbox-ops skill encodes.
+#
+# Two modes (SKILL-RESOURCE-PROTOCOL.md §7):
+#   --offline (default): NO network. Asserts the skill is internally consistent —
+#                        style-catalog.json parses, the v3 Standard config enums
+#                        (lightPreset/theme) agree between catalog and references,
+#                        the terrain tileset IDs and version gates (weather >= 3.7,
+#                        camera roll >= 3.5) are stated consistently, every classic
+#                        style url matches its id, every third-party entry is
+#                        addressable. Runs in PR CI and MAY block.
+#   --live:              network. Resolves the concrete third-party style-JSON URLs
+#                        and probes whether Mapbox GL JS has shipped a major beyond
+#                        v3 (which would mean the whole skill needs a review pass).
+#                        Runs in the scheduled freshness workflow and NEVER blocks a
+#                        PR: a transient network failure is UNAVAILABLE (exit 7), only
+#                        a confirmed change is DRIFT (exit 10).
+#
+# Usage:   check-mapbox-facts.py [--offline|--live] [--json] [-q] [--timeout SEC]
+# Input:   none (reads the skill's own assets/ + references/ relative to this file)
+# Output:  stdout = data only (text findings, or the --json envelope)
+# Stderr:  headers, progress, warnings, errors
+# Exit:    0 ok, 2 usage, 3 not-found (skill files missing), 4 validation
+#          (offline inconsistency), 5 missing-dep, 7 unavailable (live network),
+#          10 drift (live: a URL 404'd, or GL JS major bumped past v3)
+#
+# Examples:
+#   check-mapbox-facts.py --offline
+#   check-mapbox-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
+#   check-mapbox-facts.py --live --timeout 15
+"""Staleness verifier for mapbox-ops (see header comment)."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_VALIDATION = 4
+EXIT_MISSING_DEP = 5
+EXIT_UNAVAILABLE = 7
+EXIT_DRIFT = 10
+
+SCHEMA = "claude-mods.mapbox-ops.facts/v1"
+
+SKILL_ROOT = Path(__file__).resolve().parent.parent
+CATALOG = SKILL_ROOT / "assets" / "style-catalog.json"
+REFS = SKILL_ROOT / "references"
+SKILL_MD = SKILL_ROOT / "SKILL.md"
+
+# Facts the skill commits to. Changing these is a deliberate edit; the verifier
+# asserts the skill states them consistently across catalog + references.
+EXPECTED_LIGHT_PRESET = {"dawn", "day", "dusk", "night"}
+EXPECTED_THEME = {"default", "faded", "monochrome"}
+TERRAIN_DEM_ID = "mapbox.mapbox-terrain-dem-v1"
+TERRAIN_VECTOR_ID = "mapbox.mapbox-terrain-v2"
+GLJS_MAJOR = 3  # the skill is scoped to mapbox-gl-js v3.x
+
+
+class Finding:
+    __slots__ = ("check", "status", "detail")
+
+    def __init__(self, check: str, status: str, detail: str) -> None:
+        self.check = check
+        self.status = status  # ok | fail | drift | unavailable
+        self.detail = detail
+
+    def as_dict(self) -> dict:
+        return {"check": self.check, "status": self.status, "detail": self.detail}
+
+
+def read_text(path: Path) -> str:
+    return path.read_text(encoding="utf-8", errors="replace")
+
+
+# --------------------------------------------------------------------------- #
+# Offline checks                                                              #
+# --------------------------------------------------------------------------- #
+def run_offline(findings: list[Finding]) -> None:
+    # Required files present (else NOT_FOUND, distinct from inconsistency).
+    missing = [p for p in (CATALOG, SKILL_MD, REFS) if not p.exists()]
+    if missing:
+        for p in missing:
+            findings.append(Finding("files-present", "fail", f"missing: {p}"))
+        raise _NotFound()
+
+    # O1 — catalog parses.
+    try:
+        catalog = json.loads(read_text(CATALOG))
+        findings.append(Finding("catalog-json", "ok", "style-catalog.json parses"))
+    except json.JSONDecodeError as exc:
+        findings.append(Finding("catalog-json", "fail", f"invalid JSON: {exc}"))
+        return  # nothing else is checkable
+
+    presets = catalog.get("standard_presets", {})
+    v3_md = read_text(REFS / "v3-standard-style.md") if (REFS / "v3-standard-style.md").exists() else ""
+
+    # O2 — lightPreset enum: catalog matches the committed set AND each value is
+    # documented in v3-standard-style.md.
+    light = set(presets.get("lightPreset", []))
+    if light != EXPECTED_LIGHT_PRESET:
+        findings.append(Finding("lightPreset-enum", "fail",
+                                f"catalog {sorted(light)} != expected {sorted(EXPECTED_LIGHT_PRESET)}"))
+    else:
+        undoc = [v for v in EXPECTED_LIGHT_PRESET if v not in v3_md]
+        if undoc:
+            findings.append(Finding("lightPreset-enum", "fail",
+                                    f"values not documented in v3-standard-style.md: {undoc}"))
+        else:
+            findings.append(Finding("lightPreset-enum", "ok", "dawn|day|dusk|night consistent"))
+
+    # O3 — theme enum.
+    theme = set(presets.get("theme", []))
+    if theme != EXPECTED_THEME:
+        findings.append(Finding("theme-enum", "fail",
+                                f"catalog {sorted(theme)} != expected {sorted(EXPECTED_THEME)}"))
+    else:
+        findings.append(Finding("theme-enum", "ok", "default|faded|monochrome consistent"))
+
+    # O4 — terrain tileset IDs present in terrain.md.
+    terrain_md = read_text(REFS / "terrain.md") if (REFS / "terrain.md").exists() else ""
+    for tid in (TERRAIN_DEM_ID, TERRAIN_VECTOR_ID):
+        if tid in terrain_md:
+            findings.append(Finding(f"terrain-id:{tid}", "ok", "present in terrain.md"))
+        else:
+            findings.append(Finding(f"terrain-id:{tid}", "fail", "absent from terrain.md"))
+
+    # O5 — weather version gate agrees between catalog effects comment and dataviz ref.
+    effects_comment = catalog.get("effects", {}).get("_comment", "")
+    dataviz_md = read_text(REFS / "dataviz-and-3d.md") if (REFS / "dataviz-and-3d.md").exists() else ""
+    cat_ver = _first_gl_gate(effects_comment)
+    ref_ver = _first_gl_gate(dataviz_md, near="setRain") or _first_gl_gate(dataviz_md, near="Weather")
+    if cat_ver and ref_ver and cat_ver == ref_ver == "3.7":
+        findings.append(Finding("weather-gate", "ok", "GL JS >= 3.7 consistent (catalog + dataviz-and-3d.md)"))
+    else:
+        findings.append(Finding("weather-gate", "fail",
+                                f"weather version gate mismatch (catalog={cat_ver!r}, ref={ref_ver!r}, want 3.7)"))
+
+    # O6 — camera roll gate >= 3.5 stated in camera-and-animation.md.
+    camera_md = read_text(REFS / "camera-and-animation.md") if (REFS / "camera-and-animation.md").exists() else ""
+    roll_ver = _first_gl_gate(camera_md, near="roll")
+    if roll_ver == "3.5":
+        findings.append(Finding("camera-roll-gate", "ok", "native roll GL JS >= 3.5 stated"))
+    else:
+        findings.append(Finding("camera-roll-gate", "fail",
+                                f"camera roll gate = {roll_ver!r}, want 3.5"))
+
+    # O7 — GL JS major scope: SKILL.md says v3.
+    skill_md = read_text(SKILL_MD)
+    if re.search(rf"v{GLJS_MAJOR}\.x", skill_md) and re.search(rf"v{GLJS_MAJOR}\b", skill_md):
+        findings.append(Finding("gljs-major", "ok", f"skill scoped to v{GLJS_MAJOR}.x"))
+    else:
+        findings.append(Finding("gljs-major", "fail", f"SKILL.md no longer clearly scopes v{GLJS_MAJOR}.x"))
+
+    # O8 — every classic style url tail matches its id.
+    bad_urls = []
+    for s in catalog.get("styles", []):
+        sid, url = s.get("id", ""), s.get("url", "")
+        if not url.endswith("/" + sid) and not url.endswith(sid):
+            bad_urls.append(f"{sid} -> {url}")
+    if bad_urls:
+        findings.append(Finding("style-url-id", "fail", "url/id mismatch: " + "; ".join(bad_urls)))
+    else:
+        findings.append(Finding("style-url-id", "ok", f"{len(catalog.get('styles', []))} style urls match ids"))
+
+    # O9 — every third-party entry is addressable (has a url or an explanatory note).
+    unaddressable = [t.get("id", "?") for t in catalog.get("third_party", [])
+                     if not t.get("url") and not t.get("note")]
+    if unaddressable:
+        findings.append(Finding("third-party-addressable", "fail",
+                                "no url and no note: " + ", ".join(unaddressable)))
+    else:
+        findings.append(Finding("third-party-addressable", "ok",
+                                f"{len(catalog.get('third_party', []))} third-party entries addressable"))
+
+
+def _first_gl_gate(text: str, near: str | None = None) -> str | None:
+    """Return the first 'GL JS >= 3.N' version found, optionally on a line mentioning `near`."""
+    pat = re.compile(r"(?:GL JS\s*)?[>≥]=?\s*(3\.\d+)")
+    if near:
+        for line in text.splitlines():
+            if near in line:
+                m = pat.search(line)
+                if m:
+                    return m.group(1)
+        return None
+    m = pat.search(text)
+    return m.group(1) if m else None
+
+
+class _NotFound(Exception):
+    pass
+
+
+# --------------------------------------------------------------------------- #
+# Live checks                                                                 #
+# --------------------------------------------------------------------------- #
+def run_live(findings: list[Finding], timeout: float) -> None:
+    import urllib.error
+    import urllib.request
+
+    try:
+        catalog = json.loads(read_text(CATALOG))
+    except (OSError, json.JSONDecodeError) as exc:
+        findings.append(Finding("catalog-json", "fail", f"cannot read catalog: {exc}"))
+        raise _NotFound()
+
+    def probe(url: str) -> str:
+        """Return resolved | notfound | unavailable for a URL (HEAD, GET fallback)."""
+        for method in ("HEAD", "GET"):
+            req = urllib.request.Request(url, method=method,
+                                         headers={"User-Agent": "mapbox-ops-staleness/1"})
+            try:
+                with urllib.request.urlopen(req, timeout=timeout) as resp:
+                    return "resolved" if resp.status < 400 else "unavailable"
+            except urllib.error.HTTPError as e:
+                if e.code in (404, 410):
+                    return "notfound"
+                if e.code in (403, 405, 429):
+                    # forbidden/method-not-allowed/rate-limited: exists or can't tell.
+                    if method == "HEAD":
+                        continue  # retry with GET
+                    return "unavailable" if e.code == 429 else "resolved"
+                return "unavailable"
+            except (urllib.error.URLError, TimeoutError, OSError):
+                return "unavailable"
+        return "unavailable"
+
+    # L1 — concrete third-party style URLs (skip templated/keyed ones).
+    for t in catalog.get("third_party", []):
+        url = t.get("url")
+        if not url or "<" in url or "key=" in url:
+            continue
+        res = probe(url)
+        status = {"resolved": "ok", "notfound": "drift", "unavailable": "unavailable"}[res]
+        findings.append(Finding(f"url:{t.get('id', url)}", status, url))
+
+    # L2 — has Mapbox GL JS shipped a major beyond v3? A live v4.0.0 on the CDN
+    # means the skill's scope assumption needs a human review pass (drift, not error).
+    cdn = "https://api.mapbox.com/mapbox-gl-js/v{}.0.0/mapbox-gl.js"
+    v3 = probe(cdn.format(GLJS_MAJOR))
+    if v3 == "unavailable":
+        findings.append(Finding("gljs-cdn", "unavailable", "Mapbox CDN unreachable"))
+    else:
+        nxt = probe(cdn.format(GLJS_MAJOR + 1))
+        if nxt == "resolved":
+            findings.append(Finding("gljs-major-bump", "drift",
+                                    f"mapbox-gl-js v{GLJS_MAJOR + 1}.0.0 is live — review skill scope"))
+        elif nxt == "unavailable":
+            findings.append(Finding("gljs-major-bump", "unavailable",
+                                    f"could not probe v{GLJS_MAJOR + 1} (network)"))
+        else:
+            findings.append(Finding("gljs-major-bump", "ok",
+                                    f"v{GLJS_MAJOR} current; no v{GLJS_MAJOR + 1} GA"))
+
+
+# --------------------------------------------------------------------------- #
+# Main                                                                        #
+# --------------------------------------------------------------------------- #
+def main(argv: list[str]) -> int:
+    ap = argparse.ArgumentParser(add_help=True, description="mapbox-ops staleness verifier")
+    mode = ap.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)")
+    mode.add_argument("--live", action="store_true", help="resolve URLs + probe GL JS major (network)")
+    ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
+    ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress")
+    ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)")
+    try:
+        args = ap.parse_args(argv)
+    except SystemExit as e:
+        # argparse exits 2 on bad args (matches USAGE); 0 on --help.
+        return EXIT_USAGE if e.code not in (0, None) else EXIT_OK
+
+    live = args.live
+    mode_name = "live" if live else "offline"
+
+    def emit(msg: str) -> None:
+        if not args.quiet:
+            print(msg, file=sys.stderr)
+
+    findings: list[Finding] = []
+    emit(f"== check-mapbox-facts ({mode_name}) ==")
+    try:
+        if live:
+            run_live(findings, args.timeout)
+        else:
+            run_offline(findings)
+    except _NotFound:
+        if args.json:
+            print(json.dumps({"error": {"code": "NOT_FOUND",
+                                        "message": "skill files missing",
+                                        "details": [f.as_dict() for f in findings]}}))
+        for f in findings:
+            emit(f"  [{f.status.upper()}] {f.check}: {f.detail}")
+        return EXIT_NOT_FOUND
+
+    n_fail = sum(1 for f in findings if f.status == "fail")
+    n_drift = sum(1 for f in findings if f.status == "drift")
+    n_unavail = sum(1 for f in findings if f.status == "unavailable")
+
+    # Output: stdout is data only.
+    if args.json:
+        print(json.dumps({
+            "data": [f.as_dict() for f in findings],
+            "meta": {"mode": mode_name, "count": len(findings),
+                     "fail": n_fail, "drift": n_drift, "unavailable": n_unavail,
+                     "schema": SCHEMA},
+        }, indent=2))
+    else:
+        for f in findings:
+            print(f"{f.check}\t{f.status}\t{f.detail}")
+
+    # Progress summary to stderr.
+    for f in findings:
+        if f.status != "ok":
+            emit(f"  [{f.status.upper()}] {f.check}: {f.detail}")
+    emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable")
+
+    # Exit precedence: a real inconsistency (offline) or 404 (live) is the loudest
+    # signal; an unavailable network is advisory and must never mask a clean run as
+    # failing — but if the ONLY non-ok results are unavailable, exit 7, never 0.
+    if n_fail:
+        return EXIT_VALIDATION
+    if n_drift:
+        return EXIT_DRIFT
+    if n_unavail:
+        return EXIT_UNAVAILABLE
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 167 - 0
skills/mapbox-ops/scripts/screenshot_map.py

@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.
+
+Usage:   screenshot_map.py [OPTIONS] <URL> <OUT.png>
+Input:   URL of a *served* map page (http://…, not file://); PNG output path.
+         Optional --expect LNG LAT to project a coordinate to its on-canvas pixel.
+Output:  stdout = data only — a human result line, or the --json envelope (§4).
+Stderr:  progress, warnings, console-error dumps, stack-trace-free diagnostics.
+Exit:    0 ok (map ready, no console errors)
+         2 usage (bad/missing args)
+         5 precondition (playwright not installed / browser missing)
+         7 unavailable (map never signalled ready within --timeout)
+         10 domain signal (page/console errors were captured) — caller branches on this
+
+Examples:
+  screenshot_map.py http://localhost:8777/preview/index.html out.png
+  screenshot_map.py http://localhost:8777/index.html out.png --expect 146.9 -36.1
+  screenshot_map.py http://localhost:8777/index.html out.png --json | jq '.data'
+
+First run only:  uv run --with playwright python -m playwright install chromium
+
+Why served (not file://): a page that fetches GeoJSON/photos at runtime needs an HTTP
+origin and a same-origin canvas (else createImageBitmap taints). Serve one with:
+  python -m http.server 8777 --directory <site-dir>
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+# Semantic exit codes (SKILL-RESOURCE-PROTOCOL §5).
+EX_OK, EX_USAGE, EX_PRECONDITION, EX_UNAVAILABLE, EX_DOMAIN = 0, 2, 5, 7, 10
+
+READY_JS = """
+() => {
+  try {
+    if (window.__mapReady === true) return true;
+    const m = window.map;
+    if (!m) return false;
+    if (typeof m.loaded === 'function' && m.loaded()) return true;
+    if (typeof m.isStyleLoaded === 'function' && m.isStyleLoaded()) return true;
+    return false;
+  } catch (e) { return false; }
+}
+"""
+
+PROJECT_JS = """
+([lng, lat]) => {
+  const m = window.map;
+  if (!m) return null;
+  const r = m.getCanvas().getBoundingClientRect();
+  const p = m.project([lng, lat]);
+  return { canvas: {x: p.x, y: p.y},
+           page: {x: r.left + p.x, y: r.top + p.y},
+           size: {w: r.width, h: r.height} };
+}
+"""
+
+SCHEMA = "claude-mods.mapbox-ops.screenshot_map/v1"
+
+
+def emit_json(data: dict, code: int) -> int:
+    """Print the §4 success/error envelope to stdout and return the exit code."""
+    if code in (EX_OK, EX_DOMAIN):
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA, "exit": code}}))
+    else:
+        print(json.dumps({"error": {"code": data.get("code", "ERROR"),
+                                    "message": data.get("message", ""),
+                                    "details": data}}))
+    return code
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        prog="screenshot_map.py", add_help=True,
+        description="Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.")
+    ap.add_argument("url", help="served map page URL (http://…, not file://)")
+    ap.add_argument("out", help="screenshot output path (.png)")
+    ap.add_argument("--expect", nargs=2, type=float, metavar=("LNG", "LAT"),
+                    help="project this lng/lat and report its pixel")
+    ap.add_argument("--width", type=int, default=1280)
+    ap.add_argument("--height", type=int, default=800)
+    ap.add_argument("--timeout", type=int, default=20000, help="readiness timeout (ms)")
+    ap.add_argument("--json", action="store_true", help="emit the structured §4 envelope")
+    args = ap.parse_args()
+
+    as_json = args.json
+    if not args.url.startswith(("http://", "https://")):
+        msg = "URL must be http(s):// (the page must be served, not file://)"
+        print(f"error: {msg}", file=sys.stderr)
+        return emit_json({"code": "USAGE", "message": msg}, EX_USAGE) if as_json else EX_USAGE
+
+    try:
+        from playwright.sync_api import sync_playwright
+    except ImportError:
+        msg = ("playwright not installed — run: uv run --with playwright "
+               "python -m playwright install chromium")
+        print(f"error: {msg}", file=sys.stderr)
+        return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
+
+    errors: list[str] = []
+    ready = False
+    projection: dict | None = None
+
+    with sync_playwright() as p:
+        try:
+            browser = p.chromium.launch()
+        except Exception as e:                       # browser binary not installed
+            msg = f"chromium launch failed — run: python -m playwright install chromium ({e})"
+            print(f"error: {msg}", file=sys.stderr)
+            return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
+
+        page = browser.new_page(viewport={"width": args.width, "height": args.height},
+                                device_scale_factor=2)
+        page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
+        page.on("pageerror", lambda e: errors.append(str(e)))
+
+        page.goto(args.url, wait_until="networkidle")
+        try:
+            page.wait_for_function(READY_JS, timeout=args.timeout)
+            ready = True
+        except Exception:
+            print(f"warn: map not ready within {args.timeout}ms "
+                  "(set window.__mapReady=true at end of init() for an exact signal)",
+                  file=sys.stderr)
+
+        page.screenshot(path=args.out, full_page=False)
+        print(f"screenshot → {args.out}", file=sys.stderr)   # status → stderr, not data
+
+        if args.expect:
+            projection = page.evaluate(PROJECT_JS, args.expect)
+            if not projection:
+                print("warn: window.map not found (expose it: `window.map = map`)", file=sys.stderr)
+
+        browser.close()
+
+    # Result assembly
+    data = {"out": args.out, "ready": ready, "errorCount": len(errors), "errors": errors[:20]}
+    if projection:
+        cx, cy = projection["canvas"]["x"], projection["canvas"]["y"]
+        w, h = projection["size"]["w"], projection["size"]["h"]
+        projection["inside"] = bool(0 <= cx <= w and 0 <= cy <= h)
+        data["projection"] = projection
+
+    # Decide exit code: console/page errors are the domain signal (10); never-ready is 7.
+    code = EX_DOMAIN if errors else (EX_UNAVAILABLE if not ready else EX_OK)
+
+    if as_json:
+        return emit_json(data, code)
+
+    # plain-text data product on stdout
+    line = f"ready={ready} errors={len(errors)} out={args.out}"
+    if projection:
+        line += (f" project={tuple(args.expect)}→canvas("
+                 f"{projection['canvas']['x']:.0f},{projection['canvas']['y']:.0f}) "
+                 f"{'inside' if projection['inside'] else 'OUTSIDE'}")
+    print(line)
+    if errors:
+        print(f"\n{len(errors)} console/page error(s):", file=sys.stderr)
+        for e in errors[:20]:
+            print("  - " + e, file=sys.stderr)
+    return code
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 104 - 0
skills/mapbox-ops/tests/run.sh

@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# Offline self-test for the mapbox-ops skill — structure, frontmatter, script contract.
+#
+# Usage:   tests/run.sh
+# Input:   none (self-contained; no network, no playwright, no browser)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
+#
+# Examples:
+#   tests/run.sh
+#   bash skills/mapbox-ops/tests/run.sh
+set -uo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fail=0
+pass=0
+note() { printf '  %s %s\n' "$1" "$2" >&2; }
+ok()   { pass=$((pass+1)); note "ok  " "$1"; }
+bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
+
+# Resolve a *working* python (python3, else python). The bare `command -v` is not
+# enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero.
+PY=""
+for cand in python3 python; do
+  if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
+    PY="$cand"; break
+  fi
+done
+if [ -z "$PY" ]; then
+  echo "SKIP: no working python interpreter on this platform" >&2
+  exit 0
+fi
+
+# 1. Required directories exist
+for d in scripts references assets tests; do
+  [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
+done
+
+# 2. SKILL.md frontmatter house rules (SKILL-SUBAGENT-REFERENCE)
+skill="$here/SKILL.md"
+if [ -f "$skill" ]; then
+  ok "SKILL.md present"
+  grep -q '^name: mapbox-ops$' "$skill" && ok "name matches directory" || bad "name != mapbox-ops"
+  grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
+  grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
+else
+  bad "SKILL.md missing"
+fi
+
+# 3. Every reference cited from SKILL.md actually exists, and vice-versa
+for ref in "$here"/references/*.md; do
+  base="references/$(basename "$ref")"
+  grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
+done
+
+# 4. Bundled resources referenced from SKILL.md exist on disk
+for res in assets/circular_image_marker.js scripts/screenshot_map.py; do
+  [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
+done
+
+# 5. screenshot_map.py — script contract (§10)
+py="$here/scripts/screenshot_map.py"
+if [ -f "$py" ]; then
+  "$PY" -m py_compile "$py" && ok "py_compile clean" || bad "py_compile failed"
+  head -25 "$py" | grep -Eq '^(# )?Examples:' && ok "has Examples block" || bad "no Examples block"
+  "$PY" "$py" --help >/dev/null 2>&1 && ok "--help exits 0" || bad "--help nonzero"
+  # USAGE (exit 2) on a file:// URL — happens before the playwright import, so this is offline-safe
+  "$PY" "$py" "file:///tmp/x.html" /tmp/o.png >/dev/null 2>&1
+  [ "$?" -eq 2 ] && ok "file:// URL → exit 2 (USAGE)" || bad "file:// URL did not exit 2"
+else
+  bad "screenshot_map.py missing"
+fi
+
+# 5b. check-mapbox-facts.py — staleness verifier contract (§7, §10), offline-safe
+facts="$here/scripts/check-mapbox-facts.py"
+if [ -f "$facts" ]; then
+  ok "resource present: scripts/check-mapbox-facts.py"
+  "$PY" -m py_compile "$facts" && ok "facts: py_compile clean" || bad "facts: py_compile failed"
+  head -30 "$facts" | grep -Eq '^# +Examples:' && ok "facts: has Examples block" || bad "facts: no Examples block"
+  "$PY" "$facts" --help >/dev/null 2>&1 && ok "facts: --help exits 0" || bad "facts: --help nonzero"
+  # Offline mode must pass on the skill's own content (internal consistency).
+  "$PY" "$facts" --offline >/dev/null 2>&1 && ok "facts: --offline consistent (exit 0)" || bad "facts: --offline found drift"
+  # Bad flag → USAGE (exit 2); stays offline.
+  "$PY" "$facts" --bogus >/dev/null 2>&1
+  [ "$?" -eq 2 ] && ok "facts: bad flag → exit 2 (USAGE)" || bad "facts: bad flag did not exit 2"
+  # stdout is data-only: --offline --json must emit parseable JSON with no stderr leakage.
+  "$PY" "$facts" --offline --json -q 2>/dev/null | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"].startswith("claude-mods.mapbox-ops")' \
+    && ok "facts: --json envelope parses (stdout clean)" || bad "facts: --json envelope broken"
+  # cited from SKILL.md
+  grep -qF "scripts/check-mapbox-facts.py" "$skill" && ok "facts: cited from SKILL.md" || bad "facts: uncited"
+else
+  bad "check-mapbox-facts.py missing"
+fi
+
+# 6. circular_image_marker.js — node --check if node present (optional)
+js="$here/assets/circular_image_marker.js"
+if command -v node >/dev/null 2>&1; then
+  node --check "$js" >/dev/null 2>&1 && ok "js syntax (node --check)" || bad "js syntax error"
+else
+  note "skip" "node absent — js syntax check skipped"
+fi
+
+echo "mapbox-ops self-test: $pass passed, $fail failed" >&2
+[ "$fail" -eq 0 ]

+ 6 - 1
tests/check-resources.sh

@@ -53,10 +53,15 @@ echo "== ytdlp-ops: version/staleness verifier"
 run "ytdlp-ops --offline consistent" 0 bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --offline
 run "ytdlp-ops --help"               0 bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --help
 
+echo "== mapbox-ops: fact/staleness verifier"
+run "mapbox-ops --offline consistent" 0 "$PY" skills/mapbox-ops/scripts/check-mapbox-facts.py --offline
+run "mapbox-ops --help"               0 "$PY" skills/mapbox-ops/scripts/check-mapbox-facts.py --help
+
 echo "== protocol: every new verifier is executable + compiles"
 for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/claude-code-ops/scripts/validate-hooks-json.py \
-         skills/playwright-ops/scripts/triage-flakes.py; do
+         skills/playwright-ops/scripts/triage-flakes.py \
+         skills/mapbox-ops/scripts/check-mapbox-facts.py; do
     "$PY" -m py_compile "$s" 2>/dev/null && pass "py_compile $(basename "$s")" || bad "py_compile $(basename "$s")"
 done
 bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \