{"owner":"opengeos","repo":"GeoLibre","hasSkills":true,"totalSkillsCount":23,"totalTokensCount":24188,"categories":["anthropic-skill","claude-rule","plugin-manifest","project-spec"],"hasMcp":false,"mcpConfig":null,"found":["extensions/geolibre-chrome/STORE_LISTING.md","extensions/geolibre-chrome/PRIVACY.md","packages/ui/package.json","packages/ui/tsconfig.json","extensions/geolibre-chrome/manifest.json","CLAUDE.md","extensions/geolibre-chrome/README.md","docs/architecture.md","packages/processing/package.json","packages/plugins/package.json","packages/processing/tsconfig.json","packages/plugins/tsconfig.json","packages/map/package.json","packages/map/tsconfig.json","packages/embed/package.json","packages/embed/README.md","packages/embed/tsconfig.build.json","packages/embed/tsconfig.json","packages/core/package.json","packages/core/tsconfig.json","packages/collab-core/package.json","packages/collab-core/tsconfig.json","apps/geolibre-desktop/public/plugins/README.md"],"skills":{"extensions/geolibre-chrome/STORE_LISTING.md":"# Chrome Web Store listing\n\nPublished listing:\n<https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj>\n\n## Name\n\nOpen data in GeoLibre\n\n## Summary\n\nFind geospatial datasets and map services on a webpage and open them in GeoLibre.\n\n## Detailed description\n\nOpen data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map.\n\nClick the extension icon to find supported data links on the current page, filter them by vector, raster, or LiDAR type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds (LAS, LAZ, COPC, and EPT), and ZIP archives containing GeoJSON.\n\nThe extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages.\n\nInteractive maps are supported too. The extension recognizes the WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests the current page has already made.\n\nThe extension reads the page only when you click its icon, holds no standing access to any website, runs nothing in the background, and stores only one setting: the GeoLibre address \"Open in GeoLibre\" uses, which you can point at your own instance. It runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it.\n\nDataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred.\n\n## Category\n\nDeveloper Tools\n\n## Language\n\nEnglish\n\n## Permission justification\n\nEach block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. As of 0.4.0 the manifest requests `activeTab`, `scripting`, and `storage`, so the webRequest and host-permission fields still do not appear.\n\n### activeTab\n\nactiveTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time.\n\n### scripting\n\nscripting injects two packaged functions into the active tab when the user opens the popup. One reads the page's links and metadata to find dataset files. The other reads back the addresses of the requests the page has already made, so the map services it draws can be recognized; a map fetches those from JavaScript, so they are never links in the document. Both functions are contained in the extension package, so no remote code is involved. They run once per invocation and return their results to the popup.\n\n### storage\n\nstorage holds a single setting: the base URL \"Open in GeoLibre\" opens, in case the user runs their own GeoLibre instance instead of the hosted default. It is read only when the popup opens and only written from the extension's own settings page. No other data is stored.\n\n### Not requested\n\nThe extension requests no host permissions, and no permission to watch network requests or run in the background. It does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. Answer \"No, I am not using remote code\": every script it runs ships inside the package.\n","extensions/geolibre-chrome/PRIVACY.md":"# Privacy policy — Open data in GeoLibre\n\nLast updated: August 18, 2026\n\nOpen data in GeoLibre does not independently collect, retain, or sell personal\ninformation, page contents, or usage analytics. It does forward the complete\ndataset, service, and style URLs that the user explicitly selects,\nas described below. The only data it stores is an optional custom GeoLibre\nbase URL, described in the settings section below.\n\nThe extension uses Chrome's `activeTab` permission to inspect the current page\nonly after the user clicks the extension's toolbar icon. In that moment it reads\nthe page's links and structured metadata, and reads back the addresses of the\nrequests the page has already made, from the Resource Timing record each\ndocument keeps of its own loading, to identify geospatial services used by\ninteractive maps. It does not inspect response bodies, and it does not observe\nthe network or run in the background at any other time. Nothing is stored: the\nlist exists only while the popup is open and is discarded when it closes.\n\nWhen the user chooses **Open in GeoLibre**, the complete selected HTTP(S)\ndataset and style URLs are placed in the query string of a new tab opened on\nthe configured GeoLibre destination: `https://web.geolibre.app/` by default, or\nthe instance the user set on the settings page (see below), in which case that\ninstance receives the URLs instead. These URLs are forwarded verbatim and may\ncontain signed query parameters, access tokens, user identifiers, or other\npersonal data. Do not select a URL containing information you do not want to\nsend to that GeoLibre instance.\n\nThe navigation request exposes its URL and the user's IP address to GeoLibre's\nweb-hosting infrastructure, where standard service logs may retain them. The\nnavigation may also appear in browser history. See the current\n[GeoLibre privacy policy](https://geolibre.app/privacy/) for the service's data\npractices. The extension itself never persists a URL.\n\nThe extension does not fetch or upload the datasets. GeoLibre requests them\ndirectly from their original servers, subject to those servers' privacy\npolicies and CORS configuration. Cookies and other browser-session credentials\nfrom the source page are not forwarded, although credentials embedded directly\nin a selected URL are part of the URL and are forwarded.\n\nThe extension's settings page (open it from `chrome://extensions`) lets the\nuser replace the default `https://web.geolibre.app/` destination with a\ndifferent GeoLibre instance, for example a self-hosted one. That single URL is\nkept in Chrome's synced extension storage, which Chrome may sync across the\nuser's signed-in browsers under their own Google account; it is never sent\nanywhere by the extension itself except as the destination of the \"Open in\nGeoLibre\" tab it opens. Clearing it in the settings page or uninstalling the\nextension removes it.\n\nThe extension uses no remote code, advertising, analytics, tracking pixels,\ncookies, or accounts. It holds no host permissions and no permission to\nobserve browsing.\n\nQuestions may be submitted through the GeoLibre repository:\n<https://github.com/opengeos/GeoLibre/issues>.\n","packages/ui/package.json":"{\n  \"name\": \"@geolibre/ui\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./globals.css\": \"./src/globals.css\",\n    \"./theme.css\": \"./src/theme.css\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.3.1\",\n    \"@radix-ui/react-direction\": \"^1.2.0\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.2.1\",\n    \"@radix-ui/react-label\": \"^2.1.17\",\n    \"@radix-ui/react-scroll-area\": \"^1.4.0\",\n    \"@radix-ui/react-separator\": \"^1.2.0\",\n    \"@radix-ui/react-slider\": \"^1.6.1\",\n    \"@radix-ui/react-slot\": \"^1.5.0\",\n    \"@radix-ui/react-tabs\": \"^1.2.1\",\n    \"@radix-ui/react-tooltip\": \"^1.4.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-react\": \"^1.58.0\",\n    \"react\": \"^19.3.1\",\n    \"react-dom\": \"^19.3.1\",\n    \"tailwind-merge\": \"^3.7.0\",\n    \"tw-animate-css\": \"^1.4.0\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.3.0\",\n    \"@types/react-dom\": \"^19.3.0\",\n    \"typescript\": \"^7.0.2\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  }\n}\n","packages/ui/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","extensions/geolibre-chrome/manifest.json":"{\n  \"manifest_version\": 3,\n  \"name\": \"Open data in GeoLibre\",\n  \"description\": \"Find geospatial datasets and services used by the current page and open them in GeoLibre.\",\n  \"version\": \"0.4.0\",\n  \"homepage_url\": \"https://geolibre.app/\",\n  \"minimum_chrome_version\": \"106\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"Open data in GeoLibre\",\n    \"default_popup\": \"popup.html\",\n    \"default_icon\": {\n      \"16\": \"icons/geolibre-16.png\",\n      \"32\": \"icons/geolibre-32.png\"\n    }\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": false\n  },\n  \"icons\": {\n    \"16\": \"icons/geolibre-16.png\",\n    \"32\": \"icons/geolibre-32.png\",\n    \"48\": \"icons/geolibre-48.png\",\n    \"128\": \"icons/geolibre-128.png\"\n  },\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self'; object-src 'self'\"\n  }\n}\n","CLAUDE.md":"# CLAUDE.md\n\nGuidance for Claude Code (claude.ai/code) when working in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`,\n`workers/*`) plus two non-npm components: a Python FastAPI sidecar\n(`backend/geolibre_server`) and a separate Python package (`python/`, the\n`geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS\nworkspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2**\n(`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx\n(Docker), and embedded in Jupyter (the `python/` package bundles a build of the\nweb app into its wheel).\n\n## Commands\n\n```bash\nnpm run dev            # web dev server → http://localhost:5173\nnpm run tauri:dev      # desktop app (required for filesystem dialogs, local MBTiles, local raster reads)\nnpm run build          # production web build → apps/geolibre-desktop/dist/\nnpm run lite:build     # same, but DuckDB-WASM from jsDelivr — for hosts with a per-asset size cap\nnpm run tauri:build    # desktop installers → apps/geolibre-desktop/src-tauri/target/release/bundle/\nnpm run typecheck      # alias for the full build (tsc -b && vite build) — writes to dist/, not a pure type-check\nnpm run ci             # full local gate: lint + ci:frontend + typecheck:tests + ci:backend + check:rust\nnpm run ci:frontend    # build + frontend + worker tests (CI runs the halves as parallel jobs)\nnpm run ci:backend     # backend + docker/tests (needs Python and the npm CLI, no npm install)\n```\n\nTests:\n\n```bash\nnpm run test:frontend                              # node --test over tests/*.test.ts (tsx loader)\nnpm run test:frontend:coverage                     # same, plus a coverage summary (gated on a floor)\nnode --import tsx --test tests/<name>.test.ts      # a single frontend test file\nnpm run test:backend                               # pytest backend/geolibre_server/tests\nnpm run test:backend:coverage                      # same, plus pytest-cov (gated on a floor)\npython -m pytest backend/geolibre_server/tests/test_x.py::test_y   # a single backend test\nnpm run test:worker                                # typecheck workers/viewer\nnpm run test:e2e                                   # Playwright smoke tests (e2e/) against the built web app\nnpm run check:rust                                 # cargo check the Tauri crate\nnpm run typecheck:tests                            # tsc over tests/, ratcheted on an error-count baseline (docs/maintenance.md)\ncd python && pytest                                # the geolibre Python package's own suite\n```\n\nSetup notes that bite:\n\n- Install the backend **`test`** extra or the vector/raster/SQL/ML tests skip\n  themselves and CI is green but hollow:\n  `pip install -e \"backend/geolibre_server[test]\"`.\n- `npm run test:e2e` needs `npx playwright install chromium` once. It builds the\n  app, serves it with `vite preview`, and drives it with Playwright; add specs\n  under `e2e/`.\n- Coverage floors act as a **ratchet** and the frontend report only counts files\n  a test imports, so the first test for a big module can read as a regression.\n  See [Coverage floors](docs/maintenance.md#coverage-floors) before touching a\n  floor.\n\nThe `python/` package is built into a wheel via `npm run build:embed` (produces\n`apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its\nversion is dynamic, sourced from `python/src/geolibre/__init__.py`.\n\n## Pre-commit\n\n`.pre-commit-config.yaml` includes a **local `npm-build` hook**, so\n`pre-commit run` compiles the whole app — it is slow and can touch unrelated\nbuild state. Scope it to the files you changed:\n`pre-commit run --files <paths>`. Run it before pushing.\n\n## Architecture (the parts that span files)\n\nThe app is **store-driven**. `@geolibre/core` holds the Zustand store, domain\ntypes, and the `.geolibre.json` project schema — it is the single source of\ntruth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file\n   picker, drag-and-drop, or a plugin control.\n2. Local vector files MapLibre can't render directly are converted to GeoJSON\n   in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` →\n   `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs`\n   with a DuckDB fallback; KMZ unzipped client-side), then `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer`\n   records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers`\n   (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control.\n   **You don't mutate MapLibre directly from UI** — change store state and let\n   sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for\nraster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map`\n(MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) ·\n`@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins`\n(plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed\nclient) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** built-in plugins live in `packages/plugins/src/plugins/`, are\nexported from that package's `index.ts`, and registered in\n`apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips\nor a `plugin.json` manifest; bundled drop-ins under\n`apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop\nbuilds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`):\nbacks the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio).\nThe desktop app starts it on demand. It is **optional** — Vector tools run\nclient-side with Turf.js and only use the sidecar's `/vector` endpoints\n(GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog\nfalls back to the client engine via `/vector/status`. Optional extras:\n`conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are\namd64-only. The browser build proxies the sidecar at `/sidecar` (same-origin, no\nCORS), confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles\nuse a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script):\na headless stdio MCP server that authors `.geolibre.json` files. It is layered so\nnothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob),\n`authoring.py` *applies* them to a whole project (add/remove/restyle a layer,\nmove the camera, compose the legend/colorbar/swipe controls), and both `Map` and\nthe MCP tools delegate to `authoring.py`. `server.py` is the only module that\nimports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py`\nconfines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines\nto `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself\nwithout the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it\nand the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- CSP: `connect-src`/`img-src` allow any `https:` (desktop also `http:`), so a\n  new map/tile/service host needs no CSP change. `script-src` is pinned: the\n  desktop (`tauri.conf.json`) and web (`docker/nginx.conf`) CSPs list\n  version-specific jsDelivr paths that `tests/tauri-csp.test.ts` re-derives, so\n  a bump that moves a CDN script URL (or a new script host) must update both\n  files — see [`docs/maintenance.md`](docs/maintenance.md#desktop-csp-script-src-allowlist).\n  Map/tile-host CORS for selected release assets is handled by a dev-server\n  raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in\n  `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- UI strings are translatable via **react-i18next**; catalogs live in\n  `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of\n  truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings. The UI\n  mirrors for right-to-left locales, so style new components with Tailwind's\n  logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…),\n  not the physical `ml-`/`left-` forms. **Read `docs/i18n.md` in full before any\n  i18n work** — skipping it is the most common source of locale bugs.\n- **Some things upstream doesn't export are mirrored by hand, and drift fails\n  silently.** Before bumping `maplibre-gl`, any `maplibre-gl-*` package,\n  `geolibre-wasm`, or `@tauri-apps/plugin-http` — including Dependabot PRs — read\n  [`docs/maintenance.md`](docs/maintenance.md) and run the frontend suite. That\n  page also covers the coverage floors, the `npm audit` allowlist, the npm\n  publish layout for `@geolibre/core`/`@geolibre/map`, the committed\n  `backend/geolibre_server/uv.lock`, and the generated files (Whitebox menu\n  catalog, `npm run i18n:tools`, the `skills/geolibre/` agent skill) that must be\n  regenerated or updated in the same PR.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`,\n  `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/agent-skill.md`,\n  `docs/i18n.md`, `docs/maintenance.md`, `docs/contributing.md`.\n","extensions/geolibre-chrome/README.md":"# Open data in GeoLibre\n\nA Manifest V3 Chrome extension that finds supported geospatial dataset links on\nthe current page, reads back the geospatial service requests its interactive\nmaps have already made, and opens selected data in GeoLibre.\n\n## Install\n\nThe published extension is on the Chrome Web Store:\n\n[Open data in GeoLibre](https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj)\n\n## Install locally (development)\n\n1. Open `chrome://extensions`.\n2. Enable **Developer mode**.\n3. Choose **Load unpacked**.\n4. Select this `extensions/geolibre-chrome` directory.\n\nEverything happens after you click the toolbar icon: the extension scans the\ndocument's links, and reads each reachable frame's Resource Timing buffer to\nrecognize the\nservices its maps requested. It holds no permission beyond `activeTab`,\n`scripting`, and `storage`, and runs no background service worker. The only\nthing `storage` holds is an optional custom GeoLibre base URL, set from the\nextension's settings page; nothing else is stored.\n\n## Settings\n\nRight-click the toolbar icon and choose **Options** (or open\n`chrome://extensions`, find the extension, and choose **Extension options**) to\nset a different GeoLibre base URL, for example a self-hosted instance. Leaving\nit unset keeps the default, `https://web.geolibre.app/`.\n\n## Package for the Chrome Web Store\n\nFrom the repository root, run:\n\n```bash\nnpm run build:chrome-extension\n```\n\nThe versioned upload ZIP is written to `dist/`. Store listing copy and\npermission explanations are in `STORE_LISTING.md`; the publishable privacy\npolicy source is in `PRIVACY.md`. The policy must be hosted at a public URL\nbefore submitting the listing.\n\n## Discovery\n\nThe scanner recognizes GeoJSON/JSON, GeoParquet/Parquet, PMTiles, GeoTIFF/COG,\nLiDAR point clouds (LAS/LAZ/COPC and EPT `ept.json`), and ZIP links in anchors, linked resources, selected data attributes, and\nschema.org JSON-LD `contentUrl`/`downloadUrl` fields. Existing GeoLibre deep\nlinks are unpacked, and a neighboring `name.style.json` or\n`name.geolibre.style.json` link is paired with the matching dataset. A point\ncloud whose URL has no LAS/LAZ suffix, found only because its link or metadata\nsays COPC, LAZ, or LASzip, is opened with `dataType=lidar` so GeoLibre does not\nread it as GeoJSON.\n\nSource Cooperative repository pages receive additional handling: the extension\nreads the complete embedded file inventory even when the visible table is\nvirtualized, canonicalizes links to `data.source.coop`, and removes duplicate\npage/download links. The popup can filter discovered files by vector, raster,\nor LiDAR type without changing the current selection.\n\n## Services\n\nA map fetches its tiles and service documents from JavaScript, so they are never\nlinks in the page. What the extension reads instead is `performance\n.getEntriesByType(\"resource\")`, the record of its own requests that every\ndocument keeps, collected from the top frame and every same-origin frame below\nit. Recognized\nare WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, XYZ/TMS image\ntiles, and PBF/MVT vector tiles. Tile requests are collapsed into reusable\n`{z}/{x}/{y}` templates, and repeated requests from the same service appear once.\n\nThree consequences of reading the buffer rather than watching the network:\n\n- **Worker requests are invisible.** MapLibre and similar renderers fetch vector\n  tiles from a web worker, which records them in the worker's own timeline, not\n  the document's. Such a tileset is recovered from the metadata the main thread\n  *did* fetch: its TileJSON, or failing that the style document, which GeoLibre\n  can resolve a layer from on its own. A style is only offered in its own right\n  when no tileset from its origin was found.\n\n  A TileJSON is recognized by name (`tile.json`, `tiles.json`, `tilejson.json`),\n  which is narrower than every way a server can name one: tileserver-gl serves\n  `/data/<id>.json`, and that tileset is reached through its style rather than\n  its metadata. Nothing reads the response, so a TileJSON describing *raster*\n  tiles is indistinguishable from a vector one and is offered as a vector\n  tileset. Such a false positive cannot become a layer: Add Data resolves the\n  document on submit and refuses it when no source layers come out. Reading the\n  body here would mean fetching a cross-origin URL, which needs the host\n  permissions this design exists to avoid.\n\n  A style stands as a candidate of its own only when its path names it one:\n  `…/style.json`, `…/styles.json`, or an ArcGIS `…/resources/styles/<name>.json`.\n  The looser `…/styles/<name>.json` is an ordinary theme or configuration route\n  as well, so a style matched that way is still trusted to explain a tileset\n  found at its origin, but never offered on its own, where a page's theme file\n  would appear as a layer.\n- **The buffer is finite.** It holds 250 entries per document by default and\n  stops recording once full. A map's own early requests are normally well inside\n  that, but a very busy page can lose a service added late. Raising the limit\n  needs a `document_start` script, which needs the broad host permissions this\n  design exists to avoid, so the cap is accepted.\n- **Cross-origin frames are out of reach.** `activeTab` grants the tab's main\n  frame origin, and Chrome deliberately does not extend that grant to a frame\n  from another origin. `allFrames: true` therefore reaches the top frame and its\n  same-origin frames; injection into a cross-origin frame is refused, and the\n  refusal is per-frame, so the frames that *are* reachable still return their\n  buffers. A map that runs entirely inside a cross-origin `iframe` is thus\n  invisible to the scan, and the popup reports no services rather than an error.\n  Reaching one needs host permission for that origin — the standing access this\n  design exists to avoid — so the boundary is accepted.\n\nA service endpoint on its own is rarely enough to add a layer, so each result\nalso carries what the page asked that service *for*: the WMS `LAYERS` value, the\nWFS `typeName`, the WMTS layer, and — for a vector tileset, whose source layers\nlive in its style rather than its URL — the style document the page loaded from\nthe same origin. These arrive in GeoLibre as `serviceLayer` and `serviceStyle`\nand land in the matching form fields, so the dialog opens ready to submit rather\nthan on an endpoint with an empty layer field. Because one endpoint can serve\nmany layers, results are listed per layer (`WMS service: topp:states`) instead of\ncollapsing into a single entry per URL.\n\n## Sample websites for manual testing\n\nAfter loading or reloading the unpacked extension, open one of these websites in\na new tab, let its map finish drawing, then open the extension and confirm it\nlists the expected service. Selecting the result opens the matching GeoLibre Add\nData dialog with the service URL filled in. Each row below is a live third-party\nmap, so what it detects is what a real page actually requests.\n\n| Service | Website | Detected service | Layer carried over |\n| --- | --- | --- | --- |\n| WMS | [OpenLayers \"Image WMS\" example](https://openlayers.org/en/latest/examples/wms-image.html) | `https://ahocevar.com/geoserver/wms` | `topp:states` |\n| WMTS | [OpenLayers \"WMTS\" example](https://openlayers.org/en/latest/examples/wmts.html) | The `GetTile` request as a tile template | `sgmc2` |\n| WFS | [OpenLayers \"WFS\" example](https://openlayers.org/en/latest/examples/vector-wfs.html) | `https://ahocevar.com/geoserver/wfs` | `osm:water_areas` |\n| OGC API Features | [pygeoapi lakes collection](https://demo.pygeoapi.io/master/collections/lakes/items?f=html) | `https://demo.pygeoapi.io/master/collections/lakes/items` | — |\n| ArcGIS Feature Service | [OpenLayers \"Vector ESRI\" example](https://openlayers.org/en/latest/examples/vector-esri.html) | The `…/FeatureServer/0` layer URL | `0` |\n| XYZ raster tiles | [openstreetmap.org](https://www.openstreetmap.org/) | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | — |\n| Vector tiles, in a same-origin `iframe` | [MapLibre \"Display a map\" example](https://maplibre.org/maplibre-gl-js/docs/examples/display-a-map/) | `https://demotiles.maplibre.org/tiles/tiles.json` | style `…/style.json` |\n\nEach row above adds a layer that draws, with no further typing: that is the bar\nfor this table. A row that opens the dialog but leaves a required field empty is\na bug, not an expected extra step.\n\nThe MapLibre row is worth keeping in the set: the map runs inside a same-origin\n`iframe` (the docs page embeds `../display-a-map.html`), so it covers services a\npage reaches only through an embedded frame — the reachable kind, since a\ncross-origin frame is outside `activeTab` — and it renders\nthrough a worker, so it covers the tileset recovered from its TileJSON rather\nthan from a tile request. It also carries a style whose glyph ranges are served\nas `.pbf`; those are fonts, not a tileset, and must not be offered.\n\nOpening a service URL directly is detected too — the response is the page, so\n[a WMS GetCapabilities document](https://ows.terrestris.de/osm/service?SERVICE=WMS&REQUEST=GetCapabilities)\nlists `https://ows.terrestris.de/osm/service`.\n\nA document's buffer is its own and is discarded when the tab navigates, so a page\nwith no services (`https://example.com`) comes up empty rather than inheriting\nthe page before it.\n\nAfter selecting a result, add the layer in GeoLibre and verify that the browser\nconsole does not report a CORS error while fetching the service.\n\nRemote servers must allow GeoLibre to fetch the selected URLs through CORS.\nComplete HTTP(S) URLs, including signed query parameters, are forwarded to\nGeoLibre. Cookies and other browser-session credentials are not forwarded, so\ncookie-bound or session-authenticated links may fail. Temporary `blob:` and\nbrowser-internal links cannot be transferred.\n","docs/architecture.md":"# GeoLibre Architecture\n\n## Overview\n\nGeoLibre is a free and open-source, lightweight, cloud-native GIS platform that runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks, all from a single npm workspaces monorepo. The UI is a React app that ships as a native desktop app hosted by Tauri v2 and as a browser-based web app, adapting responsively to mobile and small screens. Four engines can render a map: MapLibre GL JS (the default), Mapbox GL JS, CesiumJS, and the ArcGIS Maps SDK for JavaScript. deck.gl supplies advanced raster, point-cloud, and 3D overlays where an engine supports them. Application state lives in a Zustand store (`@geolibre/core`). See the user-facing [rendering-engine guide](user-guide/rendering-engines.md) and the detailed [MapLibre](maplibre-renderer.md), [Mapbox](mapbox-renderer.md), [Cesium](cesium-renderer.md), and [ArcGIS](arcgis-renderer.md) references.\n\n```mermaid\nflowchart LR\n  UI[React UI] --> Store[Zustand Store]\n  Store --> Engine[MapEngine interface]\n  Engine --> ML[MapLibre GL JS]\n  Engine --> MB[Mapbox GL JS]\n  Engine --> CZ[CesiumJS]\n  Engine --> AG[ArcGIS Maps SDK]\n  Plugins[Built-in plugins] --> Store\n  Plugins --> Engine\n  UI --> Tauri[Tauri Plugins]\n  Tauri --> FS[File System]\n  Proc[Processing] --> Store\n  Proc -.-> Sidecar[FastAPI optional]\n```\n\n## Packages\n\n| Package                | Responsibility                                                                                 |\n| ---------------------- | ---------------------------------------------------------------------------------------------- |\n| `@geolibre/core`       | Domain types, project JSON schema, global store                                                |\n| `@geolibre/map`        | Rendering-engine lifecycles and adapters, layer sync, GeoJSON, raster, tile, MBTiles, controls, and selection styling |\n| `@geolibre/ui`         | Shared UI primitives (shadcn-style)                                                            |\n| `@geolibre/processing` | Client-side algorithm registry                                                                 |\n| `@geolibre/plugins`    | Plugin interface and built-in plugins                                                          |\n| `@geolibre/embed`      | Typed, dependency-free client for the iframe embed API, published to npm                       |\n| `@geolibre/collab-core` | Transport-neutral collaboration relay core (wire protocol, permissions, payload validators) shared by the `workers/collab` and `workers/collab-node` relays |\n| `geolibre-desktop`     | Shell layout, Tauri I/O, composition                                                           |\n\n## State flow\n\n1. User adds data through the Add Data menu, the Tauri dialog, browser file picker, drag and drop, or a built-in plugin control.\n2. Local vector data is parsed directly or converted to GeoJSON with DuckDB-WASM Spatial, then passed to `addGeoJsonLayer` in the store.\n3. Tile, service, raster, ArcGIS, MBTiles, and plugin-backed layers create `GeoLibreLayer` records with source metadata and native MapLibre layer ids when applicable.\n4. The selected `MapEngine` subscribes to `layers` and reconciles them with its SDK. MapLibre uses `MapController.syncLayers`; Mapbox, Cesium, and ArcGIS use their engine-specific adapters. The layer-control glue shared by the two Style-Spec engines lives in `packages/map/src/layer-control-host.ts`.\n5. Style panel and layer panel updates change layer state, then map sync updates paint, visibility, opacity, ordering, and removal.\n6. Attribute table selections update the highlighted feature source and can zoom the map to the selected feature.\n7. Desktop save uses `projectFromStore` and writes `.geolibre` to disk. The earlier `.geolibre.json` name remains readable.\n\nEvery layer action replaces the `layers` array, so `useAppStore((s) => s.layers)` re-renders a component on any edit of any layer. Only subscribers that genuinely need every field of every layer (the map canvases' sync, the Layers panel) should do that. Everything else uses the narrow hooks in `packages/core/src/layer-selectors.ts`: `useLayer(id)` for one record, `useLayerIds()` for the id list, `useLayerSummaries()` for `{ id, name, type, visible, groupId }` rows, and `useLayersWhen(open)` for always-mounted dialogs and panels that need the full array only while open.\n\n`useAppStore` is one Zustand store, but its code is split into slices under `packages/core/src/store/` (project, map view, layers, layer groups, project content, processing, libraries, session, collaboration, capabilities, and the `ui` dialog flags), composed in `packages/core/src/store.ts`. Add a new field or action to the slice that owns its data; an action that must also update another slice's fields (as `removeLayer` scrubs references everywhere) reads and writes the whole state through `set`/`get`. Undo history (`store/undo-history.ts`) tracks only the fields `partializeHistory` lists (layers, layer groups, basemap, story map, comments, dashboard widgets, legend). Everything else, including the whole `ui` sub-state, the selection and the camera, never records an undo step and is never reverted by undo or redo. Secondary panes and the Print Layout also reference layers but stay untracked (panes carry their own camera and must match the untracked grid layout; the composer writes back a local copy), so each undo/redo step scrubs their references to layers the step removed.\n\n## Rendering-engine model\n\nThe Zustand store is renderer-neutral: it contains plain `GeoLibreLayer`\nrecords, preferences, and `MapViewState` values rather than objects owned by a\nmapping SDK. MapLibre, Mapbox, Cesium, and ArcGIS each implement the shared\n`MapEngine` interface for camera operations, capability checks, picking,\ncontrols, placement, extent drawing, and capture. `primaryRenderer` selects the\nmain canvas, while each `SecondaryMapView.viewKind` selects the engine for a\nsplit pane. Changing either value unmounts the previous engine and connects the\nreplacement to the same project state.\n\nCapabilities are explicit rather than assumed. The shell gates commands against\nthe active `MapEngine`; layers report engine compatibility; and plugins declare\nan `engines` list. This lets a project retain an unsupported layer or suspended\nplugin across a switch without asking every SDK to emulate every feature.\n\nInside `packages/map/src`, a file named for an engine (`arcgis-*`, `cesium-*`,\n`mapbox-*`, and MapLibre's `layer-sync`, `map-controller`, and `MapCanvas`)\nnever imports another engine's file. Code two engines share lives in a neutral\nmodule named for what it does (`pmtiles-archive.ts`, `protocol-tiles.ts`,\n`gl-style-compiler.ts`, `feature-style.ts`, and so on). The\n`local/no-cross-engine-imports` ESLint rule\n(`eslint-rules/no-cross-engine-imports.mjs`) enforces the boundary and lists\nthe classification.\n\n## 3D globe view (CesiumJS)\n\nThe default MapLibre map can be joined by — or replaced with — a 3D globe rendered with [CesiumJS](https://cesium.com/platform/cesiumjs/). Cesium subscribes to the same engine-neutral store described above, whether it owns the primary canvas or one pane in a mixed-engine grid. MapLibre remains the default; integrations that paint directly through the MapLibre style API still require MapLibre.\n\n- **Where it renders.** Two places, both drawing the same store state:\n  - **As a secondary pane.** Each pane in the map grid carries a 2D/3D toggle (`SecondaryMapView.viewKind`), so a globe sits beside the 2D map for comparison.\n  - **As the primary map** (issue #2217). **View → Rendering engine → Cesium** selects the globe through the store's `primaryRenderer`. `DesktopShell` mounts `PrimaryCesiumCanvas` in place of the previous engine's canvas, so that renderer is not merely hidden — it is unmounted and stops consuming GPU resources. Switching adds or removes no panes, and a multi-pane grid can still mix all four engines. `CesiumCanvas` distinguishes the two roles by whether it is given a `viewId`: with one it is a pane backed by a `secondaryMapViews` record (own camera, own visibility overrides); without one it _is_ the primary map, reading and writing the shared `mapView` directly and ignoring the `syncView` toggle (which exists to make panes follow the primary camera). Because every engine reads the same store, the camera, basemap, layers, groups, visibility, and opacity survive the switch untouched where supported.\n- **What the globe cannot do (yet).** Every tool that drives a `MapController` — the map context menu, legend, comments, story map, terrain settings, the ML panels, and the MapLibre-typed plugin API — is MapLibre-only. Rather than leave them broken, `DesktopShell` does not mount them while the globe owns the primary area, and the View menu greys out the items that drive a controller (zoom, viewport history, Reset Orientation, Set View, the Google Maps/Earth hand-offs) instead of letting them silently do nothing. Renderer-neutral, store-driven surfaces (the Layers panel, Style Manager, the attribute table, processing, the Dashboard) stay available. Layer kinds the globe cannot draw stay in the project and are tagged \"2D only\" in the Layers panel, and come back when MapLibre does. A renderer-neutral controller interface is the follow-up milestone in #2217.\n- **Enabling it.** The 2D/3D pane toggle and the Rendering engine menu are both offered whether or not a Cesium Ion token is configured — the globe draws the project basemap, which needs no token. A token adds Cesium World Terrain and supplies Ion World Imagery as the fallback for a basemap with no raster form; without one the view shows a one-line hint saying so. The token is resolved through `getCesiumIonToken()` (`@geolibre/core`), which reads `VITE_CESIUM_TOKEN`/`CESIUM_TOKEN` from the build **or** from a runtime override, so it can be set at build time (`CESIUM_TOKEN`; see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion)) or at runtime with no rebuild. Settings → Environment Variables has a dedicated masked **Cesium Ion token** field backed by device-local `DesktopSettings` (the OS credential store on desktop, localStorage on the web; never the shared project file); `useRuntimeEnvironmentVariables` projects it into `VITE_CESIUM_TOKEN` on the `window.__GEOLIBRE_RUNTIME_ENV__` global (empty values are not projected, so they cannot blank a build-time token). The shared `useCesiumIonToken()` hook re-resolves the token on the `geolibre:runtime-env-change` event and both mount sites key the globe on it, so a newly entered token takes effect without a reload.\n- **Basemap.** `basemapToCesiumImagery()` (`@geolibre/core`) translates the project's `basemapStyleUrl` into a plain descriptor of what the globe should draw, and `applyBasemapImagery()` (`packages/map/src/cesium-basemap.ts`) turns that into imagery layers. The split keeps the decision engine-free and beside the basemap catalogs it reads, so the 2D and 3D renderers share one source of truth. Planetary and regional basemaps are already raster tile sets and carry straight over (TMS sources get `{y}` swapped for Cesium's `{reverseY}`, and a hybrid basemap's labels overlay rides directly above its imagery); GeoLibre's own vector styles map to a keyless raster basemap of matching tone, so a dark project basemap gives a dark globe; the blank basemap leaves a bare ellipsoid. Anything else — a provider style, a custom URL, an offline archive — has no raster form, and the renderer falls back to Ion World Imagery when a token is configured and OpenStreetMap when not. The basemap layers are inserted at the bottom of the imagery stack (index 0, the overlay at 1), which is what keeps them below the data layers `CesiumLayerSync` appends and raises to the top.\n- **Lazy loading.** The whole Cesium engine (~4.8 MB) is `import()`-ed only when a globe first mounts, kept in its own build chunk (`CODE_SPLITTING_GROUPS` in `vite.config.ts`) and off the 2D boot path. The build fails if Cesium, or more than 3 MB of JS, ends up in the app entry's static import graph (`bootBundleBudgetPlugin`). A Vite plugin (`vite-plugins/copy-cesium-assets.ts`) stages Cesium's runtime Workers/Assets/Widgets into `public/cesium/` and the canvas sets `window.CESIUM_BASE_URL` so the engine finds them.\n- **Drawing.** Both engines implement `drawExtent` with a shared pointer lifecycle and geographic extents. Cesium uses a draggable entity for manual placement and ground-clamped rectangles for extraction previews. Escape, pointer cancellation, focus loss, and renderer teardown restore navigation. The raster-subset panel uses these operations on both renderers; crossing extents retain west > east so the extractor can explain that they must be split. The basemap extraction panel shares the drawing helper, while its menu remains gated on vector-style support.\n- **Camera sync.** `packages/map/src/cesium-camera.ts` converts between MapLibre's Web-Mercator `MapViewState` (zoom, nadir-referenced pitch, bearing) and Cesium's camera (metric range, horizon-referenced pitch, heading), matched by **ground resolution** (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. `CesiumCanvas` seeds its camera from the shared `mapView`, applies store changes to the globe, and writes the globe's own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→`moveEnd` echo so there is no jitter loop.\n- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: \"cluster\"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package and reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285), streamed by view like EPT below (issue #2261), and plain LAS/LAZ files (issue #2261), which have no octree to sample and so are downloaded whole (up to 512 MB, decoded size included), decoded with the same package's LAS reader, thinned to every n-th point within the same budget, and reprojected through their GeoTIFF GeoKeys or, failing those, their WKT or the one the LiDAR control recorded. A compound WKT (a projected CRS paired with a vertical one, which proj4 cannot parse) is reduced to its horizontal part, and heights are converted to metres from the vertical unit, else the projected CRS's linear unit, the LAS convention for state-plane clouds in feet. A cloud with no CRS at all is refused rather than read as degrees. COPC archives and Entwine Point Tile datasets (`ept.json`, issue #2261), which run to tens of billions of points for a USGS 3DEP county, are streamed by view rather than sampled once (`cesium-point-cloud-stream.ts`, fed by `openCopcSource` and `openEptSource`; COPC's octree cube and root spacing give the same geometry EPT's manifest states): when the camera settles, the octree nodes that intersect the view are chosen most-needed first, by how coarse each node's point spacing looks at its distance from the camera, so a tilted view refines the ground underfoot before the horizon; the root always stays as the cloud's outline, a refresh only drops old nodes once the new ones are shown, decoded nodes are cached (3 million points) so panning back is free, and at most 1.5 million points are held as primitives. Subtree hierarchy files load when the walk reaches them, LASzip nodes (64 MB cap each) are fetched four at a time and decoded with the same LAS reader, and the CRS comes from the manifest's EPSG code (through the GeoKey path, for its unit) or its WKT, with an inverse projection to find which nodes a view covers; `binary` and `zstandard` datasets are refused. All three decoders drop ASPRS noise (classes 7 and 18) and stretch the height ramp over the 1st to 99th percentile of the decoded heights, so a few outliers do not flatten the colours — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. Zarr layers draw through zarr-cesium's `ZarrLayerProvider` (`cesium-zarr-imagery.ts`, issue #2261), fed from the same record the 2D Zarr control and the ArcGIS renderer read (the URL, a registered store or kerchunk manifest, the variable, selector, colour limits, and ramp); a Time Slider step or restyle updates the live provider and swaps in a fresh `ImageryLayer` over it rather than reopening the store, and a grid in a CRS other than EPSG:4326/3857 is reported as a layer error. zarr-cesium imports the `cesium` wrapper, which Vite aliases to `@cesium/engine` so the globe keeps one engine instance. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles, and ArcGIS vector tiles with a resolved style) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. ArcGIS vector-tile imports persist their resolved sources and prefixed style layers, allowing the headless sync to recreate them after a renderer switch or project reload and apply visibility, filters, and opacity while preserving service colors; the Style panel offers only opacity, order, zoom range, and filters for those layers rather than color editors that would not reach them. Older ArcGIS records without that resolved style remain 2D-only and must be re-added. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, legacy `arcgis` records without a resolved style, local point-cloud files the LiDAR control read from disk (no URL to fetch), EPT datasets in `binary` or `zstandard` form, raw `.ply`/`.splat` splat files, and the screen-space deck.gl viz kinds (heatmap, screen grid, contour) are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those \"2D only\". Deck.gl Layer builder records have no deck.gl on the globe; `cesium-deck-viz.ts` (issue #2261) rewrites each one, memoized per record and per data array so an opacity drag reuses the data source, into a record the globe already draws: scatterplot, icon, and text rows become points (fixed-size dots, pin markers, or labels), arcs become 3D parabolas over the great circle while lines and great circles stay on the ground, trips draw their whole paths without the trail animation, hexagon and grid rows are binned in JS into polygons coloured from the 2D ramp and extruded by the same 0..1000 m elevation range times the elevation scale, a GeoJSON viz passes through with its extrusion property, and glTF model rows become a CZML document of `model` packets with their altitude, bearing, and scale. The Style panel's colour and line width reach the globe the way the 2D overlay bridges them. Native KML/KMZ documents use `KmlDataSource`, preserving document styling, network links, and overlays; visibility and opacity are applied without replacing the original color properties. Local KMZ archives persist as data URLs. The Elevation Profile plugin draws native entities and samples the active terrain provider, rejecting results when that provider changes.\n- **Globe interaction parity.** The primary globe publishes ground cursor coordinates and optional terrain elevation to the shared status bar, clearing them on pointer exit and teardown. Projected scene modes report a Mercator projection. Home, scene-mode, and fullscreen widgets participate in shared control positioning without remounting their DOM. In the Controls menu they answer to the existing entries: **Compass** governs the Home (reset view) button and **Globe** governs the scene-mode picker, as the globe's counterparts of the 2D reset-bearing and projection controls, and the toolbar replays those choices onto a freshly mounted globe.\n- **Environment plugins.** The Sun simulation, Atmospheric Effects, Clouds, Precipitation, and Flight Simulator plugins declare Cesium alongside the 2D engines (Sun, Effects, and the Flight Simulator list MapLibre, Mapbox, and Cesium; Clouds and Precipitation also list ArcGIS) and branch on `app.getCesiumScene()` (issue #2287), a typed handle to the primary globe's namespace, widget, scene, camera, and clock that `CesiumEngine.getCesiumScene()` exposes as the globe's counterpart to `getMap()`. On the globe the Sun drives a native `SunLight`, `globe.enableLighting`, and the scene clock (the night-side depth maps onto `globe.vertexShadowDarkness`); Effects toggles the sky box and atmosphere and re-tints `SkyAtmosphere` by hue/saturation/brightness shifts from the halo settings; the Flight Simulator shares its keyboard, physics, and HUD with both 2D maps behind a camera adapter — `camera.setView` each frame with `screenSpaceCameraController.enableInputs` suspended on the globe, `setFreeCameraOptions` on Mapbox, `calculateCameraOptionsFromCameraLngLatAltRotation` on MapLibre; the weather overlays are store tile layers and render through `CesiumLayerSync` unchanged. The Spinning Globe control needs no branch: it drives the store through the control host's MapLibre facade. `DesktopShell` re-attaches these plugins after either engine mounts, so a renderer swap rebinds them the way a MapLibre re-init does.\n- **Plugin controls.** `CesiumControlHost` (`packages/map/src/cesium-control-host.ts`) hands a plugin control the same recording map the ArcGIS host does (issue #3088): `createShadowStyle` records its Style Spec calls, a store layer that mirrors one of its layers (`controlLayerMirrors`, including MapLibre's derived `layer-<id>-fill` ids) is drawn by `CesiumLayerSync`, and every other GeoJSON layer is evaluated by `shadowOverlayGraphics` (`shadow-overlay.ts`) and drawn as ground-clamped entities by `cesium-control-overlay.ts` in a host-owned `CustomDataSource`, with rhumb arcs so a straight 2D segment stays straight and points and labels past the horizon hidden. Layer-scoped events come from the shared `control-layer-events.ts`. Custom layers throw, so a control that needs MapLibre's WebGL context fails to mount rather than mounting dark; the host's COG helpers import rasters as store `cog` records instead (`native-raster-import.ts`). Because Cesium cancels `pointerdown`, the host re-dispatches the `mousedown`/`mousemove`/`mouseup` events the browser then drops, for controls that draw by dragging. `getControlMap(app)` resolves this map through `getCesiumControlMap`, and plugins still opt in with `engines`.\n- **Persistence.** A pane's `viewKind` is part of `SecondaryMapView` and round-trips through the `.geolibre.json` project format (`normalizeSecondaryMapViews`), so a project saved with a globe pane reopens as the globe, token or not. The workspace's own choice persists as the top-level `primaryRenderer` (`normalizePrimaryRenderer`), written only when it is not the default — so a MapLibre project is byte-identical to one saved before the setting existed, and a 3D-first project reopens directly on the globe.\n\n## DuckDB-WASM\n\nVector file import uses DuckDB-WASM for formats that need conversion before MapLibre can render them:\n\n```sql\nINSTALL spatial;\nLOAD spatial;\n```\n\nGeoParquet is read with DuckDB's Parquet reader after loading Spatial. Other local vector formats are passed to Spatial `ST_Read` when the WebAssembly extension can load the GDAL-backed reader. Zipped Shapefiles are parsed with `shpjs` first, then DuckDB Spatial is tried if that parser cannot read the file. KML files (and the KML inside unzipped KMZ archives) are read by an in-house parser that preserves embedded symbology, emitting [simplestyle-spec](https://github.com/mapbox/simplestyle-spec) properties (`fill`, `stroke`, `stroke-width`, and so on) so styled KML renders the way it does in Google Earth; KML the parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. A vector layer whose features carry simplestyle properties is rendered per-feature: paint expressions read the per-feature color/width and fall back to the flat layer style.\n\n### Apache Iceberg\n\nIceberg tables are read on the same engine, with DuckDB's core `iceberg` extension loaded alongside Spatial (`INSTALL iceberg; LOAD iceberg;`). A table addressed by its metadata location is scanned with `iceberg_scan()`; an Iceberg REST catalog is `ATTACH`ed under a fixed alias, enumerated with `SHOW ALL TABLES`, and the chosen table read as a qualified name. Either generated statement is only a default: the dialog pre-fills it into a SQL box, and whatever is in that box becomes the scan's source, so a `WHERE`, a join, or a projection can decide which geometries are rendered. A custom statement is checked with `cleanStatement`/`containsMultipleStatements` (the literal-aware helpers in `sql-workspace.ts`) before being wrapped as a sub-select, so a pasted script fails with a clear message rather than a parse error pointing at the generated wrapper.\n\nGeometry handling is deliberately narrower than the vector-file loader's. Only columns DuckDB reports as native `GEOMETRY` are candidates: Iceberg v3 has a real geometry type — `iceberg_column_definition.cpp` maps it to `LogicalType::GEOMETRY` — so a BLOB or VARCHAR here is an ordinary attribute, and the blob/base64-WKB fallbacks that make sense for plain Parquet would only produce loads that fail inside `ST_AsGeoJSON`. **The CRS comes from the schema, not the user.** DuckDB renders a CRS-annotated column as `GEOMETRY(<crs>)`, so `icebergTransformCrs` reads it and reprojects to WGS84 through `ST_Transform`; a column carrying no CRS parameter is `OGC:CRS84` by specification (the same constant DuckDB's extension hard-codes as `IcebergConstants::DefaultGeometryCRS`), which is already GeoJSON's lon/lat convention and passes through untransformed. Nothing about the coordinate system is persisted on the layer, so a table re-projected upstream stays correct on the next reload.\n\nThe work is split between `lib/iceberg.ts`, which holds the persisted layer config, the SQL builders, and the table-selection rule with no DuckDB dependency, and `lib/iceberg-loader.ts`, which owns the engine. The loader runs on the **SQL Workspace's** dedicated DuckDB instance rather than the shared one: an Iceberg scan reads the table's Parquet data files over HTTP, so it needs that instance's pre-spatial warm-up and its poisoned-instance recovery (see `resetSqlDatabase`).\n\nTwo consequences of Iceberg tables being large are wired into the app rather than left to the user. Every load is capped by a row limit, and the total row count — a `count(*)` over the configured source, which DuckDB can answer from the manifest metadata for an unfiltered table, though a custom statement's filters, joins, or projections have to be evaluated — is shown before anything is materialized. And the resulting layer, though refreshable on demand, is excluded from timer-driven refresh by `supportsAutoRefresh` in `lib/layer-refresh.ts` — re-scanning a table of that size on an interval is never what the user meant, so the automatic-refresh interval is unavailable for it rather than merely defaulted off.\n\n## Advanced Add Data workflows\n\nThe Add Data surface includes native dialogs for XYZ, WMS, WFS, vector files (via the Add Vector dialog backed by `maplibre-gl-vector`), GeoJSON URLs, vector tile sources, delimited text, raster tile templates, COG and GeoTIFF rasters (via the Add Raster dialog backed by `maplibre-gl-raster`), MBTiles, ArcGIS FeatureServer or VectorTileServer layers, Apache Iceberg tables, and GPX waypoints, tracks, and routes. It also supports 3D Tiles layers, Cesium Ion assets by id (a tileset or imagery layer with `metadata.sourceKind: \"cesium-ion\"`, offered when the globe is the primary renderer and badged \"3D only\" on the 2D map; `packages/core/src/cesium-ion.ts`, issue #2290), WFS and GeoJSON URL refresh, text marker labels, and multiple DuckDB SQL query-result layers with identify, selection, and attribute table support. The Components plugin wraps `maplibre-gl-components` panels for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, then mirrors added layers into the GeoLibre store so the Layer panel, project format, and layer control can reason about them. Additional data sources are available through the Planetary Computer and Earth Engine panels, the Overture Maps plugin, and the federal Web Services plugins. The Time Slider plugin, backed by `maplibre-gl-time-slider`, animates time series raster and vector data (COG, XYZ/WMTS, WMS-Time, and time-filtered GeoJSON) through a docked timeline, mirroring each source it adds into the GeoLibre store as an external native layer.\n\nLocal MBTiles tiles are read through a custom MapLibre protocol backed by Tauri commands. Remote rasters are fetched through the desktop backend when needed, and the local development server includes a raster proxy for selected release assets that need CORS handling.\n\n## Python sidecar\n\nThe FastAPI app in `backend/geolibre_server` backs the format Conversion tools, the raster tools, and the optional server engine for the Whitebox toolbox through a managed local processing sidecar. The desktop app starts the sidecar on demand, communicates over `127.0.0.1`, and keeps the heavier Python processing stack outside the browser bundle.\n\nThe Whitebox toolbox does **not** depend on it: its 1,000+ tools run in the browser on the `geolibre-wasm` WebAssembly runtime by default, and the sidecar is an opt-in alternative engine that can read native file paths for batch runs over a directory.\n\nThe Vector tools (Processing → GeoLibre Toolbox → Vector) run client-side with Turf.js and need no sidecar. All of the tools can optionally run on the sidecar's `/vector` endpoints, backed by GeoPandas and Shapely, for projection-aware results; the sidecar reports availability through `/vector/status`, and the dialog falls back to the client engine when the optional `vector` extra is not installed.\n\nA third Vector engine, **Python (Pyodide)**, runs the same GeoPandas/Shapely code **in the browser** via [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly), so the GeoPandas path is available on the web build with no server. The geometry logic is a framework-free module, `backend/geolibre_server/geolibre_server/vector_ops.py`, that both the sidecar and the browser run — a Vite plugin (`vite-plugins/copy-vector-ops.ts`) copies it into the app bundle, and a classic Web Worker (`public/pyodide/pyodide-worker.js`) loads Pyodide from a CDN, installs `geopandas`, and calls `run_vector_tool` over a JSON-string boundary. One source of truth means the Sidecar and Pyodide engines return identical results. The Pyodide runtime is downloaded lazily on first use; the `VITE_PYODIDE_INDEX_URL` env var points it at a self-hosted mirror for offline/production deployments.\n\nThe **SQL Workspace** offers an **Apache Sedona** engine alongside DuckDB and PostGIS. It runs Sedona spatial SQL on [SedonaDB](https://sedona.apache.org/sedonadb/), the single-node Rust (DataFusion + Arrow) engine, through the sidecar's `/sql` endpoints (`/sql/status`, `/sql/run`) backed by the optional `sedona` extra (`apache-sedona[db]`). In the browser — and on desktop when the extra is not installed — the same engine runs entirely client-side on [CereusDB](https://github.com/tobilg/cereusdb), a WebAssembly build of SedonaDB; the workspace prefers the sidecar when reachable and falls back to CereusDB automatically. The CereusDB bundle is large, so it is dynamically imported into its own chunk and only downloaded when a Sedona query first runs.\n\nThe sidecar has since grown past conversion. Its routers live in `backend/geolibre_server/geolibre_server/app/`: `raster.py` runs the Raster toolbox on rasterio/numpy/contourpy, `vector.py` backs the optional GeoPandas vector engine, `sql.py` backs the Sedona engine above, `postgis.py` and `mssql.py` load PostGIS and SQL Server tables as editable layers and write edits back, `pointcloud.py` writes point cloud annotator labels into a whole LAS/LAZ/COPC file, and `ml.py` reverse-proxies AI Segmentation to the `samgeo-api` server (SamGeo/SAM 3), which is kept out of process because SAM 3 needs a multi-gigabyte GPU Torch build. See the [Server API reference](server-api.md) for every endpoint. Leafmap and GeoAI workflows are still expected to follow the same pattern.\n\n## Offline support (PWA)\n\nThe standalone web build is an installable Progressive Web App. `vite-plugin-pwa` (configured in `apps/geolibre-desktop/vite.config.ts`) emits a web manifest plus a Workbox service worker, and `src/main.tsx` registers it next to `installStaleChunkReload` so the two coordinate. The service worker is built only for the web build; it is disabled for the Tauri desktop build (already offline via bundled assets) and the embedded Jupyter wheel (`GEOLIBRE_EMBED=1`), where `registerSW` resolves to a no-op.\n\nCaching is split to keep the first visit light:\n\n- **Precache (app shell).** The HTML and the JS/CSS chunks that boot the map are precached, so the shell loads with no network after the first visit. The heavy chunks below are excluded from the precache to avoid a large first-load download.\n- **Runtime cache, CacheFirst.** The content-hashed build assets the precache skips (everything under `/assets/`) are cached on first use: the **MapLibre** bundle, **DuckDB-WASM and its spatial extension**, and the MapLibre feature-plugin chunks. Hashed filenames make CacheFirst safe — a redeploy mints new URLs, so a stale entry is never served as current. This is what makes local-file workflows (DuckDB Spatial conversion) work offline after they have run online once. Self-hosting the spatial extension via `VITE_DUCKDB_SPATIAL_EXTENSION_PATH` keeps it same-origin so it is cached too. **PGlite/PostGIS** is not in this same-origin list: it is fetched from the jsDelivr CDN to keep it out of the build — ~25 MB raw, and ~22 MB of otherwise-incompressible weight in the Tauri binary — so the PostGIS SQL engine needs network on first use. A separate rule caches it thereafter (see below).\n- **Runtime cache, CDN engines.** A second CacheFirst rule (`geolibre-cdn-engines`) covers the heavy engines served from jsDelivr: **Pyodide** and its wheels, **PGlite/PostGIS**, the **CereusDB** (Apache Sedona) wasm, and `gdal3.js`. Their URLs embed the exact package version, so a redeploy mints new URLs and CacheFirst never serves a stale engine, and jsDelivr's permissive CORS headers mean these come back as normal (non-opaque) 200s that can be revalidated and evicted like any other entry. This is what lets the browser SQL (PostGIS), Sedona SQL, and Python features work offline **after their first online use**. When `GEOLIBRE_PGLITE_CDN=0` / `GEOLIBRE_CEREUS_CDN=0` those engines are bundled under `/assets/` instead and this rule simply never matches them; Pyodide is always CDN-loaded regardless. The rule also covers `/npm/@duckdb/`, which matches when `GEOLIBRE_DUCKDB_WASM_CDN=1` moves DuckDB off the origin (below) and when `maplibre-gl-duckdb` fetches its own engine, which it does from jsDelivr in every build.\n- **Basemaps.** Tiles and styles from the CORS-friendly default hosts (OpenFreeMap, CARTO) are runtime-cached. Other remote tiles, services, and ArcGIS/WMS/WFS sources stay network-only by design and are unavailable offline.\n\nSo each of these CDN engines needs the network on **first** use, then works offline. To remove even the first-use dependency, build with `GEOLIBRE_PGLITE_CDN=0` and `GEOLIBRE_CEREUS_CDN=0`, which vendor PGlite/PostGIS and the CereusDB wasm back into the build under `/assets/`, where the same-origin rule covers them (PGlite alone re-adds ~22 MB to the Tauri binary).\n\nTo strip **all GeoLibre-controlled** external CDN references from the build output — for deployments that cannot load any resource from untrusted CDNs — use `GEOLIBRE_NO_EXTERNAL_CDN=1`. This implies `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0`, and additionally disables features whose code references external CDNs: storymap HTML export (which injects `<script>` tags from `unpkg.com`), built-in object detection models (YOLO ONNX weights from `cdn.jsdelivr.net`), Segment Everything, 3D Tiles Draco/KTX2 decoders, and GDAL export. Pyodide remains available when `VITE_PYODIDE_INDEX_URL` points to an approved mirror. Runtime `deployment.json` configures client policy; it does not change this build-time CDN packaging.\n\n**DuckDB-WASM goes the other way**, because it is on the critical path for opening a local vector file and so is bundled by default. `GEOLIBRE_DUCKDB_WASM_CDN=1` moves it to jsDelivr instead — or `npm run lite:build`, which sets that flag and then _asserts_ no emitted file exceeds the ceiling, so a regression fails the build rather than the upload. It is pinned to the installed version by duckdb-wasm's own `getJsDelivrBundles()`, so the fetched engine cannot drift from the loader compiled into the bundle. The flag exists for one reason: `duckdb-mvp.wasm` (~40 MB) and `duckdb-eh.wasm` (~35 MB) both exceed the **25 MiB per-asset limit on Cloudflare Pages and Workers static assets**, which rejects the upload outright, and nothing else in the build comes close (the next largest is ~22 MB). So this single flag decides whether the web build can be hosted there at all — it takes the output from ~251 MB to ~176 MB with no file over the ceiling. GitHub Pages allows 100 MB per file and needs none of this. The flag is ignored for both targets that ship no service worker: a Tauri build, which must stay offline-capable, and an embed build (`GEOLIBRE_EMBED=1`), where the engine would otherwise be refetched every notebook session with no runtime cache behind it. Neither has the size ceiling this exists for, since a binary and a wheel are not uploaded to Cloudflare. A CDN-hosted worker script cannot be passed to `new Worker` (it must be same-origin), so that variant wraps it in a same-origin blob that `importScripts` the real one; this needs `worker-src blob:` and the CDN in `script-src`, both already in `docker/nginx.conf`. The nested `importScripts` is matched against `script-src` rather than `worker-src` — verified against that policy in Chromium and Firefox, since Firefox has historically checked worker sub-resources against `worker-src`/`child-src`.\n\nTwo engines have no such escape hatch. **Pyodide** is always fetched: `VITE_PYODIDE_INDEX_URL` re-points it at a mirror you host — worth doing when jsDelivr is unreachable or disallowed — but note that neither CacheFirst rule matches such a mirror, since one is scoped to `cdn.jsdelivr.net` and the other to same-origin `/assets/`, so unless the mirror is served from under `/assets/` it falls back to ordinary HTTP caching rather than the service worker. **`gdal3.js`** — which backs the Georeferencer's client-side GeoTIFF/COG export — is never vendored at all: its wasm (~28 MB) and data (~12 MB) only ever come from the CDN, and `GEOLIBRE_GDAL_CDN=0` opts out of that rather than bundling them, leaving the loader with no paths and that export unavailable.\n\nThe **desktop** build ships no service worker, so none of this runtime caching applies there: it re-fetches every CDN-loaded engine on first use per install, unless PGlite/PostGIS and CereusDB are bundled with the flags above.\n\nA new deploy is picked up via `registerType: \"autoUpdate\"`: the new service worker installs in the background and takes control (`skipWaiting` + `clientsClaim`), so its fresh precache serves subsequent requests. Workbox's default force-reload-on-activation is deliberately suppressed via `onNeedReload` in `src/main.tsx` — on the relative-base `/demo/` subpath that reload fired spuriously and discarded in-progress map state. Page recovery is delegated to `installStaleChunkReload`, which reloads on-demand only when an orphaned lazy chunk 404s (cooldown-guarded; if `sessionStorage` is blocked it skips the reload and lets the preload error surface). Precached chunks are served from cache and never 404, so the page is no longer reloaded out from under the user.\n\n## Container image\n\nThe root Dockerfile packages the browser app: a Node build stage runs the\n`geolibre-desktop` workspace build, then copies `apps/geolibre-desktop/dist`\ninto nginx. The nginx config serves static assets and falls back to `index.html`\nfor browser-entry URLs.\n\nAt startup, the entrypoint validates the selected source policy, applies\nnonblank environment overrides, generates nginx guards, conditionally starts\nthe Python sidecar, and serves the generated public `/deployment.json`. The\nbrowser fetches that policy before first render, then resolves settings from\npolicy, runtime environment and build environment, in that order. See\n[Deployment Policy](deployment-policy.md) and\n[Self-Hosting: container policy enforcement](self-hosting.md#container-policy-enforcement).\n\nThe `Publish Container Image` workflow builds the image for pull requests and\npublishes it to GitHub Container Registry for pushes to `main`, version tags,\nand manual runs. The upstream image name is `ghcr.io/opengeos/geolibre`.\n\nThe image bundles the optional Python sidecar (uvicorn) and reverse-proxies it\nat `/sidecar`; it starts only when the final policy grants `processing:run` or\n`data:add` and `GEOLIBRE_DISABLE_SIDECAR` is not `1`. The container does not\nrun the Tauri desktop shell, so workflows that depend on desktop filesystem\naccess still require the installed desktop app. Client policy and nginx guards\ndo not enforce desktop processing, browser WASM, separately exposed sidecars,\nor plugin execution.\n\n## Security\n\n- The Tauri CSP pins `script-src` to version-specific jsDelivr paths and keeps\n  `connect-src` open to `https:`/`http:` for user-supplied data URLs; see\n  [Desktop CSP `script-src` allowlist](maintenance.md#desktop-csp-script-src-allowlist).\n- File access uses dialog-selected paths only.\n\n### Credential storage\n\nTokens and API keys saved in Settings (share token, Cesium Ion token, Mapbox\ntoken, ArcGIS API key, the secret fields of AI Assistant profiles), saved\nPostGIS connection strings, and the Share.GeoLibre OAuth refresh token live in\ndifferent places per build:\n\n- **Desktop (Tauri, macOS/Windows/Linux):** the OS credential store (macOS\n  Keychain, Windows Credential Manager, Linux Secret Service) under the service\n  `org.geolibre.desktop`, one entry per credential, through the\n  `secure_store_*` commands in `src-tauri/src/secure_store.rs`. The\n  `geolibre.desktopSettings` localStorage blob keeps those fields empty, and\n  `geolibre.postgres.connectionIds` holds only the non-secret order of the\n  `postgres.connection.<uuid>` entries. The index is written before a new\n  entry, so a crash never leaves an unreferenced credential; a startup that\n  finds an indexed id without a credential (its write failed) drops the id and\n  shows the warning. `lib/credential-hydration.ts` loads them into memory\n  before the app renders and migrates any plaintext values left in\n  localStorage by an older build, removing each only after its keychain write\n  succeeds. That load is the only read: `secure_store_get_many` answers once\n  per page load of a webview (the reload hook in `lib.rs` reopens it), and\n  `secure_store_seal`, which the external-plugin loader calls before importing\n  a plugin, closes it without reading, so plugin code cannot pull saved tokens\n  from the store (issue #2858). Writes and deletes stay open. The OAuth refresh token is stored per issuer as\n  `share.oauth.refreshToken.<issuer>` and rotates on every refresh; the access\n  token and the PKCE verifier stay in memory. Because the server revokes a\n  session when a consumed refresh token is presented, the issuer is recorded\n  in the non-secret `geolibre.share.oauth.unsavedIssuers` list before each\n  keychain write and removed once the latest queued write for it completes. A\n  launch that finds its issuer listed deletes the stored token and starts\n  signed out rather than trusting a possibly consumed one. If the list itself\n  cannot be written, the new token is not stored: a delete of the stored copy\n  is queued and the sign-in lasts only until GeoLibre closes. After a refresh\n  that delete is best effort; quitting before it finishes leaves the consumed\n  token stored, so the next launch signs out on its first refresh instead of\n  at startup. Sign-out waits for its delete before reporting signed out, and\n  sends the revoke request whether or not the delete succeeds. If the delete\n  fails, Settings reports the sign-out as incomplete; the delete stays queued\n  and is retried only with a later credential write while the app runs. After\n  a restart the token is discarded if its issuer is on the unsaved list; if\n  that list could not be written either, the token is restored, and it still\n  works only if the revoke request also failed.\n  Credentials a project file would otherwise carry (geocoding API keys\n  `preferences.geocoding.apiKeys` and secret Environment Variables) are\n  stored device-wide by name as `project.geocoding.apiKey.<providerId>` and\n  `project.env.<VARIABLE>`; the non-secret\n  `geolibre.projectCredentials.accounts` list indexes them, with the same\n  index-first write order. Saving a project moves geocoding keys and uniquely\n  named secret environment values to the keychain, leaving empty values in\n  the file. Duplicate or nameless secret environment rows cannot be assigned\n  a unique account and remain for the local keep/strip save prompt.\n  Settings writes a value only when the user changes it. Clearing a field\n  deletes the stored value only if the fi","packages/processing/package.json":"{\n  \"name\": \"@geolibre/processing\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"dependencies\": {\n    \"@bjorn3/browser_wasi_shim\": \"^0.4.2\",\n    \"@geolibre/core\": \"*\",\n    \"@turf/area\": \"^7.4.0\",\n    \"@turf/bbox\": \"^7.4.0\",\n    \"@turf/bearing\": \"^7.4.0\",\n    \"@turf/boolean-contains\": \"^7.4.0\",\n    \"@turf/boolean-intersects\": \"^7.4.0\",\n    \"@turf/boolean-within\": \"^7.4.0\",\n    \"@turf/buffer\": \"^7.4.0\",\n    \"@turf/centroid\": \"^7.4.0\",\n    \"@turf/circle\": \"^7.4.0\",\n    \"@turf/convex\": \"^7.4.0\",\n    \"@turf/destination\": \"^7.3.5\",\n    \"@turf/difference\": \"^7.4.0\",\n    \"@turf/dissolve\": \"^7.4.0\",\n    \"@turf/distance\": \"^7.3.5\",\n    \"@turf/envelope\": \"^7.4.0\",\n    \"@turf/flatten\": \"^7.3.5\",\n    \"@turf/helpers\": \"^7.4.0\",\n    \"@turf/intersect\": \"^7.4.0\",\n    \"@turf/sector\": \"^7.4.0\",\n    \"@turf/simplify\": \"^7.4.0\",\n    \"@turf/tin\": \"^7.4.0\",\n    \"@turf/union\": \"^7.4.0\",\n    \"@turf/voronoi\": \"^7.4.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geolibre-wasm\": \"^1.7.1\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"onnxruntime-web\": \"1.30.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","packages/plugins/package.json":"{\n  \"name\": \"@geolibre/plugins\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./assistant-tool-registry\": \"./src/assistant-tool-registry.ts\",\n    \"./atl03\": \"./src/plugins/atl03.ts\",\n    \"./cog-spectral-profile\": \"./src/plugins/cog-spectral-profile.ts\",\n    \"./earthdata-relay\": \"./src/plugins/earthdata-relay.ts\",\n    \"./elevation-profile-chart\": \"./src/plugins/elevation-profile/chart/profileChart.ts\",\n    \"./geo-editor-geometry\": \"./src/plugins/geo-editor-geometry.ts\",\n    \"./lidar\": \"./src/plugins/components/lidar.ts\",\n    \"./local-netcdf\": \"./src/plugins/local-netcdf.ts\",\n    \"./maplibre-graticule\": \"./src/plugins/maplibre-graticule.ts\",\n    \"./maplibre-vantor/style.css\": \"./src/plugins/vantor/styles.css\",\n    \"./mgrs-reference\": \"./src/plugins/mgrs-reference.ts\",\n    \"./plugin-ids\": \"./src/plugin-ids.ts\",\n    \"./raster-symbology\": \"./src/plugins/raster-symbology.ts\",\n    \"./right-panel-registry\": \"./src/right-panel-registry.ts\",\n    \"./zarr-time-axis\": \"./src/plugins/zarr-time-axis.ts\"\n  },\n  \"dependencies\": {\n    \"@carbonplan/zarr-layer\": \"0.11.0\",\n    \"@deck.gl/aggregation-layers\": \"^9.4.0\",\n    \"@deck.gl/core\": \"^9.4.0\",\n    \"@deck.gl/geo-layers\": \"^9.4.0\",\n    \"@deck.gl/layers\": \"^9.4.0\",\n    \"@deck.gl/mapbox\": \"^9.4.0\",\n    \"@deck.gl/maplibre\": \"^9.4.0\",\n    \"@deck.gl/mesh-layers\": \"^9.4.0\",\n    \"@developmentseed/deck.gl-geotiff\": \"^0.8.1\",\n    \"@developmentseed/deck.gl-raster\": \"^0.8.1\",\n    \"@developmentseed/morecantile\": \"^0.8.1\",\n    \"@dvt3d/maplibre-three-plugin\": \"^1.7.1\",\n    \"@esri/maplibre-arcgis\": \"^1.3.1\",\n    \"@geolibre/core\": \"*\",\n    \"@geolibre/map\": \"*\",\n    \"@geoman-io/maplibre-geoman-free\": \"^0.10.1\",\n    \"@loaders.gl/i3s\": \"^4.5.4\",\n    \"@luma.gl/core\": \"^9.4.2\",\n    \"@luma.gl/engine\": \"^9.4.2\",\n    \"@luma.gl/webgl\": \"^9.4.2\",\n    \"@mapbox/vector-tile\": \"^3.0.0\",\n    \"@maplibre/maplibre-gl-directions\": \"^0.10.1\",\n    \"@strands-agents/sdk\": \"^1.21.0\",\n    \"@tauri-apps/api\": \"^2.12.4\",\n    \"@tauri-apps/plugin-opener\": \"^2.7.2\",\n    \"@turf/union\": \"^7.4.0\",\n    \"a5-js\": \"^1.0.0\",\n    \"cog-tiler-wasm\": \"^0.5.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"h3-js\": \"^4.5.0\",\n    \"h5wasm\": \"^0.10.3\",\n    \"hyparquet\": \"^1.31.4\",\n    \"hyparquet-compressors\": \"^1.1.2\",\n    \"icechunk-js\": \"^0.6.1\",\n    \"jspdf\": \"^4.2.1\",\n    \"lerc\": \"^4.2.0\",\n    \"mapillary-js\": \"^4.1.2\",\n    \"maplibre-gl\": \"^6.15.0\",\n    \"maplibre-gl-3d-tiles\": \"^0.5.11\",\n    \"maplibre-gl-basemap-control\": \"^0.14.4\",\n    \"maplibre-gl-components\": \"^0.31.2\",\n    \"maplibre-gl-duckdb\": \"^0.2.4\",\n    \"maplibre-gl-earth-engine\": \"^0.4.2\",\n    \"maplibre-gl-enviroatlas\": \"^0.1.1\",\n    \"maplibre-gl-esri-wayback\": \"^0.2.2\",\n    \"maplibre-gl-fema-wms\": \"^0.1.2\",\n    \"maplibre-gl-geo-editor\": \"^0.11.2\",\n    \"maplibre-gl-geoagent\": \"^0.6.1\",\n    \"maplibre-gl-lidar\": \"^0.21.0\",\n    \"maplibre-gl-nasa-earthdata\": \"^0.1.4\",\n    \"maplibre-gl-national-map\": \"^0.1.1\",\n    \"maplibre-gl-overture-maps\": \"^0.4.1\",\n    \"maplibre-gl-planetary-computer\": \"^0.4.0\",\n    \"maplibre-gl-raster\": \"^0.14.15\",\n    \"maplibre-gl-splat\": \"^0.2.9\",\n    \"maplibre-gl-streetview\": \"^0.8.0\",\n    \"maplibre-gl-swipe\": \"^0.14.0\",\n    \"maplibre-gl-time-slider\": \"^1.8.7\",\n    \"maplibre-gl-usgs-lidar\": \"^0.11.5\",\n    \"maplibre-gl-vector\": \"^0.11.1\",\n    \"mgrs\": \"^2.2.0\",\n    \"netcdfjs\": \"^4.0.0\",\n    \"ngeohash\": \"^0.6.4\",\n    \"open-location-code-typescript\": \"^1.5.0\",\n    \"pbf\": \"^5.1.2\",\n    \"pmtiles\": \"^4.5.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\",\n    \"satellite.js\": \"^7.1.0\",\n    \"shpjs\": \"^6.2.0\",\n    \"three\": \"^0.186.1\",\n    \"webdggrid\": \"^1.9.3\",\n    \"whitebox-wasm\": \"^0.6.1\",\n    \"zarrita\": \"^0.7.5\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"@types/ngeohash\": \"^0.6.8\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","packages/processing/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","packages/plugins/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"paths\": {}\n  },\n  \"include\": [\"src\"]\n}\n","packages/map/package.json":"# Map Skill\n\nPath: `packages/map/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/package.json)","packages/map/tsconfig.json":"# Map Skill\n\nPath: `packages/map/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/tsconfig.json)","packages/embed/package.json":"# Embed Skill\n\nPath: `packages/embed/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/package.json)","packages/embed/README.md":"# embed Documentation\n\nPath: `packages/embed/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/README.md)","packages/embed/tsconfig.build.json":"# Embed Skill\n\nPath: `packages/embed/tsconfig.build.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.build.json)","packages/embed/tsconfig.json":"# Embed Skill\n\nPath: `packages/embed/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.json)","packages/core/package.json":"# Core Skill\n\nPath: `packages/core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/package.json)","packages/core/tsconfig.json":"# Core Skill\n\nPath: `packages/core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/tsconfig.json)","packages/collab-core/package.json":"# Collab-core Skill\n\nPath: `packages/collab-core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/package.json)","packages/collab-core/tsconfig.json":"# Collab-core Skill\n\nPath: `packages/collab-core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/tsconfig.json)","apps/geolibre-desktop/public/plugins/README.md":"# plugins Documentation\n\nPath: `apps/geolibre-desktop/public/plugins/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/apps/geolibre-desktop/public/plugins/README.md)"},"files":{"extensions/geolibre-chrome/STORE_LISTING.md":"# Chrome Web Store listing\n\nPublished listing:\n<https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj>\n\n## Name\n\nOpen data in GeoLibre\n\n## Summary\n\nFind geospatial datasets and map services on a webpage and open them in GeoLibre.\n\n## Detailed description\n\nOpen data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map.\n\nClick the extension icon to find supported data links on the current page, filter them by vector, raster, or LiDAR type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds (LAS, LAZ, COPC, and EPT), and ZIP archives containing GeoJSON.\n\nThe extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages.\n\nInteractive maps are supported too. The extension recognizes the WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests the current page has already made.\n\nThe extension reads the page only when you click its icon, holds no standing access to any website, runs nothing in the background, and stores only one setting: the GeoLibre address \"Open in GeoLibre\" uses, which you can point at your own instance. It runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it.\n\nDataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred.\n\n## Category\n\nDeveloper Tools\n\n## Language\n\nEnglish\n\n## Permission justification\n\nEach block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. As of 0.4.0 the manifest requests `activeTab`, `scripting`, and `storage`, so the webRequest and host-permission fields still do not appear.\n\n### activeTab\n\nactiveTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time.\n\n### scripting\n\nscripting injects two packaged functions into the active tab when the user opens the popup. One reads the page's links and metadata to find dataset files. The other reads back the addresses of the requests the page has already made, so the map services it draws can be recognized; a map fetches those from JavaScript, so they are never links in the document. Both functions are contained in the extension package, so no remote code is involved. They run once per invocation and return their results to the popup.\n\n### storage\n\nstorage holds a single setting: the base URL \"Open in GeoLibre\" opens, in case the user runs their own GeoLibre instance instead of the hosted default. It is read only when the popup opens and only written from the extension's own settings page. No other data is stored.\n\n### Not requested\n\nThe extension requests no host permissions, and no permission to watch network requests or run in the background. It does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. Answer \"No, I am not using remote code\": every script it runs ships inside the package.\n","extensions/geolibre-chrome/PRIVACY.md":"# Privacy policy — Open data in GeoLibre\n\nLast updated: August 18, 2026\n\nOpen data in GeoLibre does not independently collect, retain, or sell personal\ninformation, page contents, or usage analytics. It does forward the complete\ndataset, service, and style URLs that the user explicitly selects,\nas described below. The only data it stores is an optional custom GeoLibre\nbase URL, described in the settings section below.\n\nThe extension uses Chrome's `activeTab` permission to inspect the current page\nonly after the user clicks the extension's toolbar icon. In that moment it reads\nthe page's links and structured metadata, and reads back the addresses of the\nrequests the page has already made, from the Resource Timing record each\ndocument keeps of its own loading, to identify geospatial services used by\ninteractive maps. It does not inspect response bodies, and it does not observe\nthe network or run in the background at any other time. Nothing is stored: the\nlist exists only while the popup is open and is discarded when it closes.\n\nWhen the user chooses **Open in GeoLibre**, the complete selected HTTP(S)\ndataset and style URLs are placed in the query string of a new tab opened on\nthe configured GeoLibre destination: `https://web.geolibre.app/` by default, or\nthe instance the user set on the settings page (see below), in which case that\ninstance receives the URLs instead. These URLs are forwarded verbatim and may\ncontain signed query parameters, access tokens, user identifiers, or other\npersonal data. Do not select a URL containing information you do not want to\nsend to that GeoLibre instance.\n\nThe navigation request exposes its URL and the user's IP address to GeoLibre's\nweb-hosting infrastructure, where standard service logs may retain them. The\nnavigation may also appear in browser history. See the current\n[GeoLibre privacy policy](https://geolibre.app/privacy/) for the service's data\npractices. The extension itself never persists a URL.\n\nThe extension does not fetch or upload the datasets. GeoLibre requests them\ndirectly from their original servers, subject to those servers' privacy\npolicies and CORS configuration. Cookies and other browser-session credentials\nfrom the source page are not forwarded, although credentials embedded directly\nin a selected URL are part of the URL and are forwarded.\n\nThe extension's settings page (open it from `chrome://extensions`) lets the\nuser replace the default `https://web.geolibre.app/` destination with a\ndifferent GeoLibre instance, for example a self-hosted one. That single URL is\nkept in Chrome's synced extension storage, which Chrome may sync across the\nuser's signed-in browsers under their own Google account; it is never sent\nanywhere by the extension itself except as the destination of the \"Open in\nGeoLibre\" tab it opens. Clearing it in the settings page or uninstalling the\nextension removes it.\n\nThe extension uses no remote code, advertising, analytics, tracking pixels,\ncookies, or accounts. It holds no host permissions and no permission to\nobserve browsing.\n\nQuestions may be submitted through the GeoLibre repository:\n<https://github.com/opengeos/GeoLibre/issues>.\n","packages/ui/package.json":"{\n  \"name\": \"@geolibre/ui\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./globals.css\": \"./src/globals.css\",\n    \"./theme.css\": \"./src/theme.css\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.3.1\",\n    \"@radix-ui/react-direction\": \"^1.2.0\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.2.1\",\n    \"@radix-ui/react-label\": \"^2.1.17\",\n    \"@radix-ui/react-scroll-area\": \"^1.4.0\",\n    \"@radix-ui/react-separator\": \"^1.2.0\",\n    \"@radix-ui/react-slider\": \"^1.6.1\",\n    \"@radix-ui/react-slot\": \"^1.5.0\",\n    \"@radix-ui/react-tabs\": \"^1.2.1\",\n    \"@radix-ui/react-tooltip\": \"^1.4.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-react\": \"^1.58.0\",\n    \"react\": \"^19.3.1\",\n    \"react-dom\": \"^19.3.1\",\n    \"tailwind-merge\": \"^3.7.0\",\n    \"tw-animate-css\": \"^1.4.0\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.3.0\",\n    \"@types/react-dom\": \"^19.3.0\",\n    \"typescript\": \"^7.0.2\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  }\n}\n","packages/ui/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","extensions/geolibre-chrome/manifest.json":"{\n  \"manifest_version\": 3,\n  \"name\": \"Open data in GeoLibre\",\n  \"description\": \"Find geospatial datasets and services used by the current page and open them in GeoLibre.\",\n  \"version\": \"0.4.0\",\n  \"homepage_url\": \"https://geolibre.app/\",\n  \"minimum_chrome_version\": \"106\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"Open data in GeoLibre\",\n    \"default_popup\": \"popup.html\",\n    \"default_icon\": {\n      \"16\": \"icons/geolibre-16.png\",\n      \"32\": \"icons/geolibre-32.png\"\n    }\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": false\n  },\n  \"icons\": {\n    \"16\": \"icons/geolibre-16.png\",\n    \"32\": \"icons/geolibre-32.png\",\n    \"48\": \"icons/geolibre-48.png\",\n    \"128\": \"icons/geolibre-128.png\"\n  },\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self'; object-src 'self'\"\n  }\n}\n","CLAUDE.md":"# CLAUDE.md\n\nGuidance for Claude Code (claude.ai/code) when working in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`,\n`workers/*`) plus two non-npm components: a Python FastAPI sidecar\n(`backend/geolibre_server`) and a separate Python package (`python/`, the\n`geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS\nworkspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2**\n(`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx\n(Docker), and embedded in Jupyter (the `python/` package bundles a build of the\nweb app into its wheel).\n\n## Commands\n\n```bash\nnpm run dev            # web dev server → http://localhost:5173\nnpm run tauri:dev      # desktop app (required for filesystem dialogs, local MBTiles, local raster reads)\nnpm run build          # production web build → apps/geolibre-desktop/dist/\nnpm run lite:build     # same, but DuckDB-WASM from jsDelivr — for hosts with a per-asset size cap\nnpm run tauri:build    # desktop installers → apps/geolibre-desktop/src-tauri/target/release/bundle/\nnpm run typecheck      # alias for the full build (tsc -b && vite build) — writes to dist/, not a pure type-check\nnpm run ci             # full local gate: lint + ci:frontend + typecheck:tests + ci:backend + check:rust\nnpm run ci:frontend    # build + frontend + worker tests (CI runs the halves as parallel jobs)\nnpm run ci:backend     # backend + docker/tests (needs Python and the npm CLI, no npm install)\n```\n\nTests:\n\n```bash\nnpm run test:frontend                              # node --test over tests/*.test.ts (tsx loader)\nnpm run test:frontend:coverage                     # same, plus a coverage summary (gated on a floor)\nnode --import tsx --test tests/<name>.test.ts      # a single frontend test file\nnpm run test:backend                               # pytest backend/geolibre_server/tests\nnpm run test:backend:coverage                      # same, plus pytest-cov (gated on a floor)\npython -m pytest backend/geolibre_server/tests/test_x.py::test_y   # a single backend test\nnpm run test:worker                                # typecheck workers/viewer\nnpm run test:e2e                                   # Playwright smoke tests (e2e/) against the built web app\nnpm run check:rust                                 # cargo check the Tauri crate\nnpm run typecheck:tests                            # tsc over tests/, ratcheted on an error-count baseline (docs/maintenance.md)\ncd python && pytest                                # the geolibre Python package's own suite\n```\n\nSetup notes that bite:\n\n- Install the backend **`test`** extra or the vector/raster/SQL/ML tests skip\n  themselves and CI is green but hollow:\n  `pip install -e \"backend/geolibre_server[test]\"`.\n- `npm run test:e2e` needs `npx playwright install chromium` once. It builds the\n  app, serves it with `vite preview`, and drives it with Playwright; add specs\n  under `e2e/`.\n- Coverage floors act as a **ratchet** and the frontend report only counts files\n  a test imports, so the first test for a big module can read as a regression.\n  See [Coverage floors](docs/maintenance.md#coverage-floors) before touching a\n  floor.\n\nThe `python/` package is built into a wheel via `npm run build:embed` (produces\n`apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its\nversion is dynamic, sourced from `python/src/geolibre/__init__.py`.\n\n## Pre-commit\n\n`.pre-commit-config.yaml` includes a **local `npm-build` hook**, so\n`pre-commit run` compiles the whole app — it is slow and can touch unrelated\nbuild state. Scope it to the files you changed:\n`pre-commit run --files <paths>`. Run it before pushing.\n\n## Architecture (the parts that span files)\n\nThe app is **store-driven**. `@geolibre/core` holds the Zustand store, domain\ntypes, and the `.geolibre.json` project schema — it is the single source of\ntruth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file\n   picker, drag-and-drop, or a plugin control.\n2. Local vector files MapLibre can't render directly are converted to GeoJSON\n   in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` →\n   `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs`\n   with a DuckDB fallback; KMZ unzipped client-side), then `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer`\n   records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers`\n   (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control.\n   **You don't mutate MapLibre directly from UI** — change store state and let\n   sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for\nraster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map`\n(MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) ·\n`@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins`\n(plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed\nclient) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** built-in plugins live in `packages/plugins/src/plugins/`, are\nexported from that package's `index.ts`, and registered in\n`apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips\nor a `plugin.json` manifest; bundled drop-ins under\n`apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop\nbuilds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`):\nbacks the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio).\nThe desktop app starts it on demand. It is **optional** — Vector tools run\nclient-side with Turf.js and only use the sidecar's `/vector` endpoints\n(GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog\nfalls back to the client engine via `/vector/status`. Optional extras:\n`conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are\namd64-only. The browser build proxies the sidecar at `/sidecar` (same-origin, no\nCORS), confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles\nuse a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script):\na headless stdio MCP server that authors `.geolibre.json` files. It is layered so\nnothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob),\n`authoring.py` *applies* them to a whole project (add/remove/restyle a layer,\nmove the camera, compose the legend/colorbar/swipe controls), and both `Map` and\nthe MCP tools delegate to `authoring.py`. `server.py` is the only module that\nimports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py`\nconfines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines\nto `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself\nwithout the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it\nand the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- CSP: `connect-src`/`img-src` allow any `https:` (desktop also `http:`), so a\n  new map/tile/service host needs no CSP change. `script-src` is pinned: the\n  desktop (`tauri.conf.json`) and web (`docker/nginx.conf`) CSPs list\n  version-specific jsDelivr paths that `tests/tauri-csp.test.ts` re-derives, so\n  a bump that moves a CDN script URL (or a new script host) must update both\n  files — see [`docs/maintenance.md`](docs/maintenance.md#desktop-csp-script-src-allowlist).\n  Map/tile-host CORS for selected release assets is handled by a dev-server\n  raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in\n  `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- UI strings are translatable via **react-i18next**; catalogs live in\n  `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of\n  truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings. The UI\n  mirrors for right-to-left locales, so style new components with Tailwind's\n  logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…),\n  not the physical `ml-`/`left-` forms. **Read `docs/i18n.md` in full before any\n  i18n work** — skipping it is the most common source of locale bugs.\n- **Some things upstream doesn't export are mirrored by hand, and drift fails\n  silently.** Before bumping `maplibre-gl`, any `maplibre-gl-*` package,\n  `geolibre-wasm`, or `@tauri-apps/plugin-http` — including Dependabot PRs — read\n  [`docs/maintenance.md`](docs/maintenance.md) and run the frontend suite. That\n  page also covers the coverage floors, the `npm audit` allowlist, the npm\n  publish layout for `@geolibre/core`/`@geolibre/map`, the committed\n  `backend/geolibre_server/uv.lock`, and the generated files (Whitebox menu\n  catalog, `npm run i18n:tools`, the `skills/geolibre/` agent skill) that must be\n  regenerated or updated in the same PR.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`,\n  `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/agent-skill.md`,\n  `docs/i18n.md`, `docs/maintenance.md`, `docs/contributing.md`.\n","extensions/geolibre-chrome/README.md":"# Open data in GeoLibre\n\nA Manifest V3 Chrome extension that finds supported geospatial dataset links on\nthe current page, reads back the geospatial service requests its interactive\nmaps have already made, and opens selected data in GeoLibre.\n\n## Install\n\nThe published extension is on the Chrome Web Store:\n\n[Open data in GeoLibre](https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj)\n\n## Install locally (development)\n\n1. Open `chrome://extensions`.\n2. Enable **Developer mode**.\n3. Choose **Load unpacked**.\n4. Select this `extensions/geolibre-chrome` directory.\n\nEverything happens after you click the toolbar icon: the extension scans the\ndocument's links, and reads each reachable frame's Resource Timing buffer to\nrecognize the\nservices its maps requested. It holds no permission beyond `activeTab`,\n`scripting`, and `storage`, and runs no background service worker. The only\nthing `storage` holds is an optional custom GeoLibre base URL, set from the\nextension's settings page; nothing else is stored.\n\n## Settings\n\nRight-click the toolbar icon and choose **Options** (or open\n`chrome://extensions`, find the extension, and choose **Extension options**) to\nset a different GeoLibre base URL, for example a self-hosted instance. Leaving\nit unset keeps the default, `https://web.geolibre.app/`.\n\n## Package for the Chrome Web Store\n\nFrom the repository root, run:\n\n```bash\nnpm run build:chrome-extension\n```\n\nThe versioned upload ZIP is written to `dist/`. Store listing copy and\npermission explanations are in `STORE_LISTING.md`; the publishable privacy\npolicy source is in `PRIVACY.md`. The policy must be hosted at a public URL\nbefore submitting the listing.\n\n## Discovery\n\nThe scanner recognizes GeoJSON/JSON, GeoParquet/Parquet, PMTiles, GeoTIFF/COG,\nLiDAR point clouds (LAS/LAZ/COPC and EPT `ept.json`), and ZIP links in anchors, linked resources, selected data attributes, and\nschema.org JSON-LD `contentUrl`/`downloadUrl` fields. Existing GeoLibre deep\nlinks are unpacked, and a neighboring `name.style.json` or\n`name.geolibre.style.json` link is paired with the matching dataset. A point\ncloud whose URL has no LAS/LAZ suffix, found only because its link or metadata\nsays COPC, LAZ, or LASzip, is opened with `dataType=lidar` so GeoLibre does not\nread it as GeoJSON.\n\nSource Cooperative repository pages receive additional handling: the extension\nreads the complete embedded file inventory even when the visible table is\nvirtualized, canonicalizes links to `data.source.coop`, and removes duplicate\npage/download links. The popup can filter discovered files by vector, raster,\nor LiDAR type without changing the current selection.\n\n## Services\n\nA map fetches its tiles and service documents from JavaScript, so they are never\nlinks in the page. What the extension reads instead is `performance\n.getEntriesByType(\"resource\")`, the record of its own requests that every\ndocument keeps, collected from the top frame and every same-origin frame below\nit. Recognized\nare WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, XYZ/TMS image\ntiles, and PBF/MVT vector tiles. Tile requests are collapsed into reusable\n`{z}/{x}/{y}` templates, and repeated requests from the same service appear once.\n\nThree consequences of reading the buffer rather than watching the network:\n\n- **Worker requests are invisible.** MapLibre and similar renderers fetch vector\n  tiles from a web worker, which records them in the worker's own timeline, not\n  the document's. Such a tileset is recovered from the metadata the main thread\n  *did* fetch: its TileJSON, or failing that the style document, which GeoLibre\n  can resolve a layer from on its own. A style is only offered in its own right\n  when no tileset from its origin was found.\n\n  A TileJSON is recognized by name (`tile.json`, `tiles.json`, `tilejson.json`),\n  which is narrower than every way a server can name one: tileserver-gl serves\n  `/data/<id>.json`, and that tileset is reached through its style rather than\n  its metadata. Nothing reads the response, so a TileJSON describing *raster*\n  tiles is indistinguishable from a vector one and is offered as a vector\n  tileset. Such a false positive cannot become a layer: Add Data resolves the\n  document on submit and refuses it when no source layers come out. Reading the\n  body here would mean fetching a cross-origin URL, which needs the host\n  permissions this design exists to avoid.\n\n  A style stands as a candidate of its own only when its path names it one:\n  `…/style.json`, `…/styles.json`, or an ArcGIS `…/resources/styles/<name>.json`.\n  The looser `…/styles/<name>.json` is an ordinary theme or configuration route\n  as well, so a style matched that way is still trusted to explain a tileset\n  found at its origin, but never offered on its own, where a page's theme file\n  would appear as a layer.\n- **The buffer is finite.** It holds 250 entries per document by default and\n  stops recording once full. A map's own early requests are normally well inside\n  that, but a very busy page can lose a service added late. Raising the limit\n  needs a `document_start` script, which needs the broad host permissions this\n  design exists to avoid, so the cap is accepted.\n- **Cross-origin frames are out of reach.** `activeTab` grants the tab's main\n  frame origin, and Chrome deliberately does not extend that grant to a frame\n  from another origin. `allFrames: true` therefore reaches the top frame and its\n  same-origin frames; injection into a cross-origin frame is refused, and the\n  refusal is per-frame, so the frames that *are* reachable still return their\n  buffers. A map that runs entirely inside a cross-origin `iframe` is thus\n  invisible to the scan, and the popup reports no services rather than an error.\n  Reaching one needs host permission for that origin — the standing access this\n  design exists to avoid — so the boundary is accepted.\n\nA service endpoint on its own is rarely enough to add a layer, so each result\nalso carries what the page asked that service *for*: the WMS `LAYERS` value, the\nWFS `typeName`, the WMTS layer, and — for a vector tileset, whose source layers\nlive in its style rather than its URL — the style document the page loaded from\nthe same origin. These arrive in GeoLibre as `serviceLayer` and `serviceStyle`\nand land in the matching form fields, so the dialog opens ready to submit rather\nthan on an endpoint with an empty layer field. Because one endpoint can serve\nmany layers, results are listed per layer (`WMS service: topp:states`) instead of\ncollapsing into a single entry per URL.\n\n## Sample websites for manual testing\n\nAfter loading or reloading the unpacked extension, open one of these websites in\na new tab, let its map finish drawing, then open the extension and confirm it\nlists the expected service. Selecting the result opens the matching GeoLibre Add\nData dialog with the service URL filled in. Each row below is a live third-party\nmap, so what it detects is what a real page actually requests.\n\n| Service | Website | Detected service | Layer carried over |\n| --- | --- | --- | --- |\n| WMS | [OpenLayers \"Image WMS\" example](https://openlayers.org/en/latest/examples/wms-image.html) | `https://ahocevar.com/geoserver/wms` | `topp:states` |\n| WMTS | [OpenLayers \"WMTS\" example](https://openlayers.org/en/latest/examples/wmts.html) | The `GetTile` request as a tile template | `sgmc2` |\n| WFS | [OpenLayers \"WFS\" example](https://openlayers.org/en/latest/examples/vector-wfs.html) | `https://ahocevar.com/geoserver/wfs` | `osm:water_areas` |\n| OGC API Features | [pygeoapi lakes collection](https://demo.pygeoapi.io/master/collections/lakes/items?f=html) | `https://demo.pygeoapi.io/master/collections/lakes/items` | — |\n| ArcGIS Feature Service | [OpenLayers \"Vector ESRI\" example](https://openlayers.org/en/latest/examples/vector-esri.html) | The `…/FeatureServer/0` layer URL | `0` |\n| XYZ raster tiles | [openstreetmap.org](https://www.openstreetmap.org/) | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | — |\n| Vector tiles, in a same-origin `iframe` | [MapLibre \"Display a map\" example](https://maplibre.org/maplibre-gl-js/docs/examples/display-a-map/) | `https://demotiles.maplibre.org/tiles/tiles.json` | style `…/style.json` |\n\nEach row above adds a layer that draws, with no further typing: that is the bar\nfor this table. A row that opens the dialog but leaves a required field empty is\na bug, not an expected extra step.\n\nThe MapLibre row is worth keeping in the set: the map runs inside a same-origin\n`iframe` (the docs page embeds `../display-a-map.html`), so it covers services a\npage reaches only through an embedded frame — the reachable kind, since a\ncross-origin frame is outside `activeTab` — and it renders\nthrough a worker, so it covers the tileset recovered from its TileJSON rather\nthan from a tile request. It also carries a style whose glyph ranges are served\nas `.pbf`; those are fonts, not a tileset, and must not be offered.\n\nOpening a service URL directly is detected too — the response is the page, so\n[a WMS GetCapabilities document](https://ows.terrestris.de/osm/service?SERVICE=WMS&REQUEST=GetCapabilities)\nlists `https://ows.terrestris.de/osm/service`.\n\nA document's buffer is its own and is discarded when the tab navigates, so a page\nwith no services (`https://example.com`) comes up empty rather than inheriting\nthe page before it.\n\nAfter selecting a result, add the layer in GeoLibre and verify that the browser\nconsole does not report a CORS error while fetching the service.\n\nRemote servers must allow GeoLibre to fetch the selected URLs through CORS.\nComplete HTTP(S) URLs, including signed query parameters, are forwarded to\nGeoLibre. Cookies and other browser-session credentials are not forwarded, so\ncookie-bound or session-authenticated links may fail. Temporary `blob:` and\nbrowser-internal links cannot be transferred.\n","docs/architecture.md":"# GeoLibre Architecture\n\n## Overview\n\nGeoLibre is a free and open-source, lightweight, cloud-native GIS platform that runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks, all from a single npm workspaces monorepo. The UI is a React app that ships as a native desktop app hosted by Tauri v2 and as a browser-based web app, adapting responsively to mobile and small screens. Four engines can render a map: MapLibre GL JS (the default), Mapbox GL JS, CesiumJS, and the ArcGIS Maps SDK for JavaScript. deck.gl supplies advanced raster, point-cloud, and 3D overlays where an engine supports them. Application state lives in a Zustand store (`@geolibre/core`). See the user-facing [rendering-engine guide](user-guide/rendering-engines.md) and the detailed [MapLibre](maplibre-renderer.md), [Mapbox](mapbox-renderer.md), [Cesium](cesium-renderer.md), and [ArcGIS](arcgis-renderer.md) references.\n\n```mermaid\nflowchart LR\n  UI[React UI] --> Store[Zustand Store]\n  Store --> Engine[MapEngine interface]\n  Engine --> ML[MapLibre GL JS]\n  Engine --> MB[Mapbox GL JS]\n  Engine --> CZ[CesiumJS]\n  Engine --> AG[ArcGIS Maps SDK]\n  Plugins[Built-in plugins] --> Store\n  Plugins --> Engine\n  UI --> Tauri[Tauri Plugins]\n  Tauri --> FS[File System]\n  Proc[Processing] --> Store\n  Proc -.-> Sidecar[FastAPI optional]\n```\n\n## Packages\n\n| Package                | Responsibility                                                                                 |\n| ---------------------- | ---------------------------------------------------------------------------------------------- |\n| `@geolibre/core`       | Domain types, project JSON schema, global store                                                |\n| `@geolibre/map`        | Rendering-engine lifecycles and adapters, layer sync, GeoJSON, raster, tile, MBTiles, controls, and selection styling |\n| `@geolibre/ui`         | Shared UI primitives (shadcn-style)                                                            |\n| `@geolibre/processing` | Client-side algorithm registry                                                                 |\n| `@geolibre/plugins`    | Plugin interface and built-in plugins                                                          |\n| `@geolibre/embed`      | Typed, dependency-free client for the iframe embed API, published to npm                       |\n| `@geolibre/collab-core` | Transport-neutral collaboration relay core (wire protocol, permissions, payload validators) shared by the `workers/collab` and `workers/collab-node` relays |\n| `geolibre-desktop`     | Shell layout, Tauri I/O, composition                                                           |\n\n## State flow\n\n1. User adds data through the Add Data menu, the Tauri dialog, browser file picker, drag and drop, or a built-in plugin control.\n2. Local vector data is parsed directly or converted to GeoJSON with DuckDB-WASM Spatial, then passed to `addGeoJsonLayer` in the store.\n3. Tile, service, raster, ArcGIS, MBTiles, and plugin-backed layers create `GeoLibreLayer` records with source metadata and native MapLibre layer ids when applicable.\n4. The selected `MapEngine` subscribes to `layers` and reconciles them with its SDK. MapLibre uses `MapController.syncLayers`; Mapbox, Cesium, and ArcGIS use their engine-specific adapters. The layer-control glue shared by the two Style-Spec engines lives in `packages/map/src/layer-control-host.ts`.\n5. Style panel and layer panel updates change layer state, then map sync updates paint, visibility, opacity, ordering, and removal.\n6. Attribute table selections update the highlighted feature source and can zoom the map to the selected feature.\n7. Desktop save uses `projectFromStore` and writes `.geolibre` to disk. The earlier `.geolibre.json` name remains readable.\n\nEvery layer action replaces the `layers` array, so `useAppStore((s) => s.layers)` re-renders a component on any edit of any layer. Only subscribers that genuinely need every field of every layer (the map canvases' sync, the Layers panel) should do that. Everything else uses the narrow hooks in `packages/core/src/layer-selectors.ts`: `useLayer(id)` for one record, `useLayerIds()` for the id list, `useLayerSummaries()` for `{ id, name, type, visible, groupId }` rows, and `useLayersWhen(open)` for always-mounted dialogs and panels that need the full array only while open.\n\n`useAppStore` is one Zustand store, but its code is split into slices under `packages/core/src/store/` (project, map view, layers, layer groups, project content, processing, libraries, session, collaboration, capabilities, and the `ui` dialog flags), composed in `packages/core/src/store.ts`. Add a new field or action to the slice that owns its data; an action that must also update another slice's fields (as `removeLayer` scrubs references everywhere) reads and writes the whole state through `set`/`get`. Undo history (`store/undo-history.ts`) tracks only the fields `partializeHistory` lists (layers, layer groups, basemap, story map, comments, dashboard widgets, legend). Everything else, including the whole `ui` sub-state, the selection and the camera, never records an undo step and is never reverted by undo or redo. Secondary panes and the Print Layout also reference layers but stay untracked (panes carry their own camera and must match the untracked grid layout; the composer writes back a local copy), so each undo/redo step scrubs their references to layers the step removed.\n\n## Rendering-engine model\n\nThe Zustand store is renderer-neutral: it contains plain `GeoLibreLayer`\nrecords, preferences, and `MapViewState` values rather than objects owned by a\nmapping SDK. MapLibre, Mapbox, Cesium, and ArcGIS each implement the shared\n`MapEngine` interface for camera operations, capability checks, picking,\ncontrols, placement, extent drawing, and capture. `primaryRenderer` selects the\nmain canvas, while each `SecondaryMapView.viewKind` selects the engine for a\nsplit pane. Changing either value unmounts the previous engine and connects the\nreplacement to the same project state.\n\nCapabilities are explicit rather than assumed. The shell gates commands against\nthe active `MapEngine`; layers report engine compatibility; and plugins declare\nan `engines` list. This lets a project retain an unsupported layer or suspended\nplugin across a switch without asking every SDK to emulate every feature.\n\nInside `packages/map/src`, a file named for an engine (`arcgis-*`, `cesium-*`,\n`mapbox-*`, and MapLibre's `layer-sync`, `map-controller`, and `MapCanvas`)\nnever imports another engine's file. Code two engines share lives in a neutral\nmodule named for what it does (`pmtiles-archive.ts`, `protocol-tiles.ts`,\n`gl-style-compiler.ts`, `feature-style.ts`, and so on). The\n`local/no-cross-engine-imports` ESLint rule\n(`eslint-rules/no-cross-engine-imports.mjs`) enforces the boundary and lists\nthe classification.\n\n## 3D globe view (CesiumJS)\n\nThe default MapLibre map can be joined by — or replaced with — a 3D globe rendered with [CesiumJS](https://cesium.com/platform/cesiumjs/). Cesium subscribes to the same engine-neutral store described above, whether it owns the primary canvas or one pane in a mixed-engine grid. MapLibre remains the default; integrations that paint directly through the MapLibre style API still require MapLibre.\n\n- **Where it renders.** Two places, both drawing the same store state:\n  - **As a secondary pane.** Each pane in the map grid carries a 2D/3D toggle (`SecondaryMapView.viewKind`), so a globe sits beside the 2D map for comparison.\n  - **As the primary map** (issue #2217). **View → Rendering engine → Cesium** selects the globe through the store's `primaryRenderer`. `DesktopShell` mounts `PrimaryCesiumCanvas` in place of the previous engine's canvas, so that renderer is not merely hidden — it is unmounted and stops consuming GPU resources. Switching adds or removes no panes, and a multi-pane grid can still mix all four engines. `CesiumCanvas` distinguishes the two roles by whether it is given a `viewId`: with one it is a pane backed by a `secondaryMapViews` record (own camera, own visibility overrides); without one it _is_ the primary map, reading and writing the shared `mapView` directly and ignoring the `syncView` toggle (which exists to make panes follow the primary camera). Because every engine reads the same store, the camera, basemap, layers, groups, visibility, and opacity survive the switch untouched where supported.\n- **What the globe cannot do (yet).** Every tool that drives a `MapController` — the map context menu, legend, comments, story map, terrain settings, the ML panels, and the MapLibre-typed plugin API — is MapLibre-only. Rather than leave them broken, `DesktopShell` does not mount them while the globe owns the primary area, and the View menu greys out the items that drive a controller (zoom, viewport history, Reset Orientation, Set View, the Google Maps/Earth hand-offs) instead of letting them silently do nothing. Renderer-neutral, store-driven surfaces (the Layers panel, Style Manager, the attribute table, processing, the Dashboard) stay available. Layer kinds the globe cannot draw stay in the project and are tagged \"2D only\" in the Layers panel, and come back when MapLibre does. A renderer-neutral controller interface is the follow-up milestone in #2217.\n- **Enabling it.** The 2D/3D pane toggle and the Rendering engine menu are both offered whether or not a Cesium Ion token is configured — the globe draws the project basemap, which needs no token. A token adds Cesium World Terrain and supplies Ion World Imagery as the fallback for a basemap with no raster form; without one the view shows a one-line hint saying so. The token is resolved through `getCesiumIonToken()` (`@geolibre/core`), which reads `VITE_CESIUM_TOKEN`/`CESIUM_TOKEN` from the build **or** from a runtime override, so it can be set at build time (`CESIUM_TOKEN`; see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion)) or at runtime with no rebuild. Settings → Environment Variables has a dedicated masked **Cesium Ion token** field backed by device-local `DesktopSettings` (the OS credential store on desktop, localStorage on the web; never the shared project file); `useRuntimeEnvironmentVariables` projects it into `VITE_CESIUM_TOKEN` on the `window.__GEOLIBRE_RUNTIME_ENV__` global (empty values are not projected, so they cannot blank a build-time token). The shared `useCesiumIonToken()` hook re-resolves the token on the `geolibre:runtime-env-change` event and both mount sites key the globe on it, so a newly entered token takes effect without a reload.\n- **Basemap.** `basemapToCesiumImagery()` (`@geolibre/core`) translates the project's `basemapStyleUrl` into a plain descriptor of what the globe should draw, and `applyBasemapImagery()` (`packages/map/src/cesium-basemap.ts`) turns that into imagery layers. The split keeps the decision engine-free and beside the basemap catalogs it reads, so the 2D and 3D renderers share one source of truth. Planetary and regional basemaps are already raster tile sets and carry straight over (TMS sources get `{y}` swapped for Cesium's `{reverseY}`, and a hybrid basemap's labels overlay rides directly above its imagery); GeoLibre's own vector styles map to a keyless raster basemap of matching tone, so a dark project basemap gives a dark globe; the blank basemap leaves a bare ellipsoid. Anything else — a provider style, a custom URL, an offline archive — has no raster form, and the renderer falls back to Ion World Imagery when a token is configured and OpenStreetMap when not. The basemap layers are inserted at the bottom of the imagery stack (index 0, the overlay at 1), which is what keeps them below the data layers `CesiumLayerSync` appends and raises to the top.\n- **Lazy loading.** The whole Cesium engine (~4.8 MB) is `import()`-ed only when a globe first mounts, kept in its own build chunk (`CODE_SPLITTING_GROUPS` in `vite.config.ts`) and off the 2D boot path. The build fails if Cesium, or more than 3 MB of JS, ends up in the app entry's static import graph (`bootBundleBudgetPlugin`). A Vite plugin (`vite-plugins/copy-cesium-assets.ts`) stages Cesium's runtime Workers/Assets/Widgets into `public/cesium/` and the canvas sets `window.CESIUM_BASE_URL` so the engine finds them.\n- **Drawing.** Both engines implement `drawExtent` with a shared pointer lifecycle and geographic extents. Cesium uses a draggable entity for manual placement and ground-clamped rectangles for extraction previews. Escape, pointer cancellation, focus loss, and renderer teardown restore navigation. The raster-subset panel uses these operations on both renderers; crossing extents retain west > east so the extractor can explain that they must be split. The basemap extraction panel shares the drawing helper, while its menu remains gated on vector-style support.\n- **Camera sync.** `packages/map/src/cesium-camera.ts` converts between MapLibre's Web-Mercator `MapViewState` (zoom, nadir-referenced pitch, bearing) and Cesium's camera (metric range, horizon-referenced pitch, heading), matched by **ground resolution** (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. `CesiumCanvas` seeds its camera from the shared `mapView`, applies store changes to the globe, and writes the globe's own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→`moveEnd` echo so there is no jitter loop.\n- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: \"cluster\"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package and reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285), streamed by view like EPT below (issue #2261), and plain LAS/LAZ files (issue #2261), which have no octree to sample and so are downloaded whole (up to 512 MB, decoded size included), decoded with the same package's LAS reader, thinned to every n-th point within the same budget, and reprojected through their GeoTIFF GeoKeys or, failing those, their WKT or the one the LiDAR control recorded. A compound WKT (a projected CRS paired with a vertical one, which proj4 cannot parse) is reduced to its horizontal part, and heights are converted to metres from the vertical unit, else the projected CRS's linear unit, the LAS convention for state-plane clouds in feet. A cloud with no CRS at all is refused rather than read as degrees. COPC archives and Entwine Point Tile datasets (`ept.json`, issue #2261), which run to tens of billions of points for a USGS 3DEP county, are streamed by view rather than sampled once (`cesium-point-cloud-stream.ts`, fed by `openCopcSource` and `openEptSource`; COPC's octree cube and root spacing give the same geometry EPT's manifest states): when the camera settles, the octree nodes that intersect the view are chosen most-needed first, by how coarse each node's point spacing looks at its distance from the camera, so a tilted view refines the ground underfoot before the horizon; the root always stays as the cloud's outline, a refresh only drops old nodes once the new ones are shown, decoded nodes are cached (3 million points) so panning back is free, and at most 1.5 million points are held as primitives. Subtree hierarchy files load when the walk reaches them, LASzip nodes (64 MB cap each) are fetched four at a time and decoded with the same LAS reader, and the CRS comes from the manifest's EPSG code (through the GeoKey path, for its unit) or its WKT, with an inverse projection to find which nodes a view covers; `binary` and `zstandard` datasets are refused. All three decoders drop ASPRS noise (classes 7 and 18) and stretch the height ramp over the 1st to 99th percentile of the decoded heights, so a few outliers do not flatten the colours — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. Zarr layers draw through zarr-cesium's `ZarrLayerProvider` (`cesium-zarr-imagery.ts`, issue #2261), fed from the same record the 2D Zarr control and the ArcGIS renderer read (the URL, a registered store or kerchunk manifest, the variable, selector, colour limits, and ramp); a Time Slider step or restyle updates the live provider and swaps in a fresh `ImageryLayer` over it rather than reopening the store, and a grid in a CRS other than EPSG:4326/3857 is reported as a layer error. zarr-cesium imports the `cesium` wrapper, which Vite aliases to `@cesium/engine` so the globe keeps one engine instance. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles, and ArcGIS vector tiles with a resolved style) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. ArcGIS vector-tile imports persist their resolved sources and prefixed style layers, allowing the headless sync to recreate them after a renderer switch or project reload and apply visibility, filters, and opacity while preserving service colors; the Style panel offers only opacity, order, zoom range, and filters for those layers rather than color editors that would not reach them. Older ArcGIS records without that resolved style remain 2D-only and must be re-added. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, legacy `arcgis` records without a resolved style, local point-cloud files the LiDAR control read from disk (no URL to fetch), EPT datasets in `binary` or `zstandard` form, raw `.ply`/`.splat` splat files, and the screen-space deck.gl viz kinds (heatmap, screen grid, contour) are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those \"2D only\". Deck.gl Layer builder records have no deck.gl on the globe; `cesium-deck-viz.ts` (issue #2261) rewrites each one, memoized per record and per data array so an opacity drag reuses the data source, into a record the globe already draws: scatterplot, icon, and text rows become points (fixed-size dots, pin markers, or labels), arcs become 3D parabolas over the great circle while lines and great circles stay on the ground, trips draw their whole paths without the trail animation, hexagon and grid rows are binned in JS into polygons coloured from the 2D ramp and extruded by the same 0..1000 m elevation range times the elevation scale, a GeoJSON viz passes through with its extrusion property, and glTF model rows become a CZML document of `model` packets with their altitude, bearing, and scale. The Style panel's colour and line width reach the globe the way the 2D overlay bridges them. Native KML/KMZ documents use `KmlDataSource`, preserving document styling, network links, and overlays; visibility and opacity are applied without replacing the original color properties. Local KMZ archives persist as data URLs. The Elevation Profile plugin draws native entities and samples the active terrain provider, rejecting results when that provider changes.\n- **Globe interaction parity.** The primary globe publishes ground cursor coordinates and optional terrain elevation to the shared status bar, clearing them on pointer exit and teardown. Projected scene modes report a Mercator projection. Home, scene-mode, and fullscreen widgets participate in shared control positioning without remounting their DOM. In the Controls menu they answer to the existing entries: **Compass** governs the Home (reset view) button and **Globe** governs the scene-mode picker, as the globe's counterparts of the 2D reset-bearing and projection controls, and the toolbar replays those choices onto a freshly mounted globe.\n- **Environment plugins.** The Sun simulation, Atmospheric Effects, Clouds, Precipitation, and Flight Simulator plugins declare Cesium alongside the 2D engines (Sun, Effects, and the Flight Simulator list MapLibre, Mapbox, and Cesium; Clouds and Precipitation also list ArcGIS) and branch on `app.getCesiumScene()` (issue #2287), a typed handle to the primary globe's namespace, widget, scene, camera, and clock that `CesiumEngine.getCesiumScene()` exposes as the globe's counterpart to `getMap()`. On the globe the Sun drives a native `SunLight`, `globe.enableLighting`, and the scene clock (the night-side depth maps onto `globe.vertexShadowDarkness`); Effects toggles the sky box and atmosphere and re-tints `SkyAtmosphere` by hue/saturation/brightness shifts from the halo settings; the Flight Simulator shares its keyboard, physics, and HUD with both 2D maps behind a camera adapter — `camera.setView` each frame with `screenSpaceCameraController.enableInputs` suspended on the globe, `setFreeCameraOptions` on Mapbox, `calculateCameraOptionsFromCameraLngLatAltRotation` on MapLibre; the weather overlays are store tile layers and render through `CesiumLayerSync` unchanged. The Spinning Globe control needs no branch: it drives the store through the control host's MapLibre facade. `DesktopShell` re-attaches these plugins after either engine mounts, so a renderer swap rebinds them the way a MapLibre re-init does.\n- **Plugin controls.** `CesiumControlHost` (`packages/map/src/cesium-control-host.ts`) hands a plugin control the same recording map the ArcGIS host does (issue #3088): `createShadowStyle` records its Style Spec calls, a store layer that mirrors one of its layers (`controlLayerMirrors`, including MapLibre's derived `layer-<id>-fill` ids) is drawn by `CesiumLayerSync`, and every other GeoJSON layer is evaluated by `shadowOverlayGraphics` (`shadow-overlay.ts`) and drawn as ground-clamped entities by `cesium-control-overlay.ts` in a host-owned `CustomDataSource`, with rhumb arcs so a straight 2D segment stays straight and points and labels past the horizon hidden. Layer-scoped events come from the shared `control-layer-events.ts`. Custom layers throw, so a control that needs MapLibre's WebGL context fails to mount rather than mounting dark; the host's COG helpers import rasters as store `cog` records instead (`native-raster-import.ts`). Because Cesium cancels `pointerdown`, the host re-dispatches the `mousedown`/`mousemove`/`mouseup` events the browser then drops, for controls that draw by dragging. `getControlMap(app)` resolves this map through `getCesiumControlMap`, and plugins still opt in with `engines`.\n- **Persistence.** A pane's `viewKind` is part of `SecondaryMapView` and round-trips through the `.geolibre.json` project format (`normalizeSecondaryMapViews`), so a project saved with a globe pane reopens as the globe, token or not. The workspace's own choice persists as the top-level `primaryRenderer` (`normalizePrimaryRenderer`), written only when it is not the default — so a MapLibre project is byte-identical to one saved before the setting existed, and a 3D-first project reopens directly on the globe.\n\n## DuckDB-WASM\n\nVector file import uses DuckDB-WASM for formats that need conversion before MapLibre can render them:\n\n```sql\nINSTALL spatial;\nLOAD spatial;\n```\n\nGeoParquet is read with DuckDB's Parquet reader after loading Spatial. Other local vector formats are passed to Spatial `ST_Read` when the WebAssembly extension can load the GDAL-backed reader. Zipped Shapefiles are parsed with `shpjs` first, then DuckDB Spatial is tried if that parser cannot read the file. KML files (and the KML inside unzipped KMZ archives) are read by an in-house parser that preserves embedded symbology, emitting [simplestyle-spec](https://github.com/mapbox/simplestyle-spec) properties (`fill`, `stroke`, `stroke-width`, and so on) so styled KML renders the way it does in Google Earth; KML the parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. A vector layer whose features carry simplestyle properties is rendered per-feature: paint expressions read the per-feature color/width and fall back to the flat layer style.\n\n### Apache Iceberg\n\nIceberg tables are read on the same engine, with DuckDB's core `iceberg` extension loaded alongside Spatial (`INSTALL iceberg; LOAD iceberg;`). A table addressed by its metadata location is scanned with `iceberg_scan()`; an Iceberg REST catalog is `ATTACH`ed under a fixed alias, enumerated with `SHOW ALL TABLES`, and the chosen table read as a qualified name. Either generated statement is only a default: the dialog pre-fills it into a SQL box, and whatever is in that box becomes the scan's source, so a `WHERE`, a join, or a projection can decide which geometries are rendered. A custom statement is checked with `cleanStatement`/`containsMultipleStatements` (the literal-aware helpers in `sql-workspace.ts`) before being wrapped as a sub-select, so a pasted script fails with a clear message rather than a parse error pointing at the generated wrapper.\n\nGeometry handling is deliberately narrower than the vector-file loader's. Only columns DuckDB reports as native `GEOMETRY` are candidates: Iceberg v3 has a real geometry type — `iceberg_column_definition.cpp` maps it to `LogicalType::GEOMETRY` — so a BLOB or VARCHAR here is an ordinary attribute, and the blob/base64-WKB fallbacks that make sense for plain Parquet would only produce loads that fail inside `ST_AsGeoJSON`. **The CRS comes from the schema, not the user.** DuckDB renders a CRS-annotated column as `GEOMETRY(<crs>)`, so `icebergTransformCrs` reads it and reprojects to WGS84 through `ST_Transform`; a column carrying no CRS parameter is `OGC:CRS84` by specification (the same constant DuckDB's extension hard-codes as `IcebergConstants::DefaultGeometryCRS`), which is already GeoJSON's lon/lat convention and passes through untransformed. Nothing about the coordinate system is persisted on the layer, so a table re-projected upstream stays correct on the next reload.\n\nThe work is split between `lib/iceberg.ts`, which holds the persisted layer config, the SQL builders, and the table-selection rule with no DuckDB dependency, and `lib/iceberg-loader.ts`, which owns the engine. The loader runs on the **SQL Workspace's** dedicated DuckDB instance rather than the shared one: an Iceberg scan reads the table's Parquet data files over HTTP, so it needs that instance's pre-spatial warm-up and its poisoned-instance recovery (see `resetSqlDatabase`).\n\nTwo consequences of Iceberg tables being large are wired into the app rather than left to the user. Every load is capped by a row limit, and the total row count — a `count(*)` over the configured source, which DuckDB can answer from the manifest metadata for an unfiltered table, though a custom statement's filters, joins, or projections have to be evaluated — is shown before anything is materialized. And the resulting layer, though refreshable on demand, is excluded from timer-driven refresh by `supportsAutoRefresh` in `lib/layer-refresh.ts` — re-scanning a table of that size on an interval is never what the user meant, so the automatic-refresh interval is unavailable for it rather than merely defaulted off.\n\n## Advanced Add Data workflows\n\nThe Add Data surface includes native dialogs for XYZ, WMS, WFS, vector files (via the Add Vector dialog backed by `maplibre-gl-vector`), GeoJSON URLs, vector tile sources, delimited text, raster tile templates, COG and GeoTIFF rasters (via the Add Raster dialog backed by `maplibre-gl-raster`), MBTiles, ArcGIS FeatureServer or VectorTileServer layers, Apache Iceberg tables, and GPX waypoints, tracks, and routes. It also supports 3D Tiles layers, Cesium Ion assets by id (a tileset or imagery layer with `metadata.sourceKind: \"cesium-ion\"`, offered when the globe is the primary renderer and badged \"3D only\" on the 2D map; `packages/core/src/cesium-ion.ts`, issue #2290), WFS and GeoJSON URL refresh, text marker labels, and multiple DuckDB SQL query-result layers with identify, selection, and attribute table support. The Components plugin wraps `maplibre-gl-components` panels for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, then mirrors added layers into the GeoLibre store so the Layer panel, project format, and layer control can reason about them. Additional data sources are available through the Planetary Computer and Earth Engine panels, the Overture Maps plugin, and the federal Web Services plugins. The Time Slider plugin, backed by `maplibre-gl-time-slider`, animates time series raster and vector data (COG, XYZ/WMTS, WMS-Time, and time-filtered GeoJSON) through a docked timeline, mirroring each source it adds into the GeoLibre store as an external native layer.\n\nLocal MBTiles tiles are read through a custom MapLibre protocol backed by Tauri commands. Remote rasters are fetched through the desktop backend when needed, and the local development server includes a raster proxy for selected release assets that need CORS handling.\n\n## Python sidecar\n\nThe FastAPI app in `backend/geolibre_server` backs the format Conversion tools, the raster tools, and the optional server engine for the Whitebox toolbox through a managed local processing sidecar. The desktop app starts the sidecar on demand, communicates over `127.0.0.1`, and keeps the heavier Python processing stack outside the browser bundle.\n\nThe Whitebox toolbox does **not** depend on it: its 1,000+ tools run in the browser on the `geolibre-wasm` WebAssembly runtime by default, and the sidecar is an opt-in alternative engine that can read native file paths for batch runs over a directory.\n\nThe Vector tools (Processing → GeoLibre Toolbox → Vector) run client-side with Turf.js and need no sidecar. All of the tools can optionally run on the sidecar's `/vector` endpoints, backed by GeoPandas and Shapely, for projection-aware results; the sidecar reports availability through `/vector/status`, and the dialog falls back to the client engine when the optional `vector` extra is not installed.\n\nA third Vector engine, **Python (Pyodide)**, runs the same GeoPandas/Shapely code **in the browser** via [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly), so the GeoPandas path is available on the web build with no server. The geometry logic is a framework-free module, `backend/geolibre_server/geolibre_server/vector_ops.py`, that both the sidecar and the browser run — a Vite plugin (`vite-plugins/copy-vector-ops.ts`) copies it into the app bundle, and a classic Web Worker (`public/pyodide/pyodide-worker.js`) loads Pyodide from a CDN, installs `geopandas`, and calls `run_vector_tool` over a JSON-string boundary. One source of truth means the Sidecar and Pyodide engines return identical results. The Pyodide runtime is downloaded lazily on first use; the `VITE_PYODIDE_INDEX_URL` env var points it at a self-hosted mirror for offline/production deployments.\n\nThe **SQL Workspace** offers an **Apache Sedona** engine alongside DuckDB and PostGIS. It runs Sedona spatial SQL on [SedonaDB](https://sedona.apache.org/sedonadb/), the single-node Rust (DataFusion + Arrow) engine, through the sidecar's `/sql` endpoints (`/sql/status`, `/sql/run`) backed by the optional `sedona` extra (`apache-sedona[db]`). In the browser — and on desktop when the extra is not installed — the same engine runs entirely client-side on [CereusDB](https://github.com/tobilg/cereusdb), a WebAssembly build of SedonaDB; the workspace prefers the sidecar when reachable and falls back to CereusDB automatically. The CereusDB bundle is large, so it is dynamically imported into its own chunk and only downloaded when a Sedona query first runs.\n\nThe sidecar has since grown past conversion. Its routers live in `backend/geolibre_server/geolibre_server/app/`: `raster.py` runs the Raster toolbox on rasterio/numpy/contourpy, `vector.py` backs the optional GeoPandas vector engine, `sql.py` backs the Sedona engine above, `postgis.py` and `mssql.py` load PostGIS and SQL Server tables as editable layers and write edits back, `pointcloud.py` writes point cloud annotator labels into a whole LAS/LAZ/COPC file, and `ml.py` reverse-proxies AI Segmentation to the `samgeo-api` server (SamGeo/SAM 3), which is kept out of process because SAM 3 needs a multi-gigabyte GPU Torch build. See the [Server API reference](server-api.md) for every endpoint. Leafmap and GeoAI workflows are still expected to follow the same pattern.\n\n## Offline support (PWA)\n\nThe standalone web build is an installable Progressive Web App. `vite-plugin-pwa` (configured in `apps/geolibre-desktop/vite.config.ts`) emits a web manifest plus a Workbox service worker, and `src/main.tsx` registers it next to `installStaleChunkReload` so the two coordinate. The service worker is built only for the web build; it is disabled for the Tauri desktop build (already offline via bundled assets) and the embedded Jupyter wheel (`GEOLIBRE_EMBED=1`), where `registerSW` resolves to a no-op.\n\nCaching is split to keep the first visit light:\n\n- **Precache (app shell).** The HTML and the JS/CSS chunks that boot the map are precached, so the shell loads with no network after the first visit. The heavy chunks below are excluded from the precache to avoid a large first-load download.\n- **Runtime cache, CacheFirst.** The content-hashed build assets the precache skips (everything under `/assets/`) are cached on first use: the **MapLibre** bundle, **DuckDB-WASM and its spatial extension**, and the MapLibre feature-plugin chunks. Hashed filenames make CacheFirst safe — a redeploy mints new URLs, so a stale entry is never served as current. This is what makes local-file workflows (DuckDB Spatial conversion) work offline after they have run online once. Self-hosting the spatial extension via `VITE_DUCKDB_SPATIAL_EXTENSION_PATH` keeps it same-origin so it is cached too. **PGlite/PostGIS** is not in this same-origin list: it is fetched from the jsDelivr CDN to keep it out of the build — ~25 MB raw, and ~22 MB of otherwise-incompressible weight in the Tauri binary — so the PostGIS SQL engine needs network on first use. A separate rule caches it thereafter (see below).\n- **Runtime cache, CDN engines.** A second CacheFirst rule (`geolibre-cdn-engines`) covers the heavy engines served from jsDelivr: **Pyodide** and its wheels, **PGlite/PostGIS**, the **CereusDB** (Apache Sedona) wasm, and `gdal3.js`. Their URLs embed the exact package version, so a redeploy mints new URLs and CacheFirst never serves a stale engine, and jsDelivr's permissive CORS headers mean these come back as normal (non-opaque) 200s that can be revalidated and evicted like any other entry. This is what lets the browser SQL (PostGIS), Sedona SQL, and Python features work offline **after their first online use**. When `GEOLIBRE_PGLITE_CDN=0` / `GEOLIBRE_CEREUS_CDN=0` those engines are bundled under `/assets/` instead and this rule simply never matches them; Pyodide is always CDN-loaded regardless. The rule also covers `/npm/@duckdb/`, which matches when `GEOLIBRE_DUCKDB_WASM_CDN=1` moves DuckDB off the origin (below) and when `maplibre-gl-duckdb` fetches its own engine, which it does from jsDelivr in every build.\n- **Basemaps.** Tiles and styles from the CORS-friendly default hosts (OpenFreeMap, CARTO) are runtime-cached. Other remote tiles, services, and ArcGIS/WMS/WFS sources stay network-only by design and are unavailable offline.\n\nSo each of these CDN engines needs the network on **first** use, then works offline. To remove even the first-use dependency, build with `GEOLIBRE_PGLITE_CDN=0` and `GEOLIBRE_CEREUS_CDN=0`, which vendor PGlite/PostGIS and the CereusDB wasm back into the build under `/assets/`, where the same-origin rule covers them (PGlite alone re-adds ~22 MB to the Tauri binary).\n\nTo strip **all GeoLibre-controlled** external CDN references from the build output — for deployments that cannot load any resource from untrusted CDNs — use `GEOLIBRE_NO_EXTERNAL_CDN=1`. This implies `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0`, and additionally disables features whose code references external CDNs: storymap HTML export (which injects `<script>` tags from `unpkg.com`), built-in object detection models (YOLO ONNX weights from `cdn.jsdelivr.net`), Segment Everything, 3D Tiles Draco/KTX2 decoders, and GDAL export. Pyodide remains available when `VITE_PYODIDE_INDEX_URL` points to an approved mirror. Runtime `deployment.json` configures client policy; it does not change this build-time CDN packaging.\n\n**DuckDB-WASM goes the other way**, because it is on the critical path for opening a local vector file and so is bundled by default. `GEOLIBRE_DUCKDB_WASM_CDN=1` moves it to jsDelivr instead — or `npm run lite:build`, which sets that flag and then _asserts_ no emitted file exceeds the ceiling, so a regression fails the build rather than the upload. It is pinned to the installed version by duckdb-wasm's own `getJsDelivrBundles()`, so the fetched engine cannot drift from the loader compiled into the bundle. The flag exists for one reason: `duckdb-mvp.wasm` (~40 MB) and `duckdb-eh.wasm` (~35 MB) both exceed the **25 MiB per-asset limit on Cloudflare Pages and Workers static assets**, which rejects the upload outright, and nothing else in the build comes close (the next largest is ~22 MB). So this single flag decides whether the web build can be hosted there at all — it takes the output from ~251 MB to ~176 MB with no file over the ceiling. GitHub Pages allows 100 MB per file and needs none of this. The flag is ignored for both targets that ship no service worker: a Tauri build, which must stay offline-capable, and an embed build (`GEOLIBRE_EMBED=1`), where the engine would otherwise be refetched every notebook session with no runtime cache behind it. Neither has the size ceiling this exists for, since a binary and a wheel are not uploaded to Cloudflare. A CDN-hosted worker script cannot be passed to `new Worker` (it must be same-origin), so that variant wraps it in a same-origin blob that `importScripts` the real one; this needs `worker-src blob:` and the CDN in `script-src`, both already in `docker/nginx.conf`. The nested `importScripts` is matched against `script-src` rather than `worker-src` — verified against that policy in Chromium and Firefox, since Firefox has historically checked worker sub-resources against `worker-src`/`child-src`.\n\nTwo engines have no such escape hatch. **Pyodide** is always fetched: `VITE_PYODIDE_INDEX_URL` re-points it at a mirror you host — worth doing when jsDelivr is unreachable or disallowed — but note that neither CacheFirst rule matches such a mirror, since one is scoped to `cdn.jsdelivr.net` and the other to same-origin `/assets/`, so unless the mirror is served from under `/assets/` it falls back to ordinary HTTP caching rather than the service worker. **`gdal3.js`** — which backs the Georeferencer's client-side GeoTIFF/COG export — is never vendored at all: its wasm (~28 MB) and data (~12 MB) only ever come from the CDN, and `GEOLIBRE_GDAL_CDN=0` opts out of that rather than bundling them, leaving the loader with no paths and that export unavailable.\n\nThe **desktop** build ships no service worker, so none of this runtime caching applies there: it re-fetches every CDN-loaded engine on first use per install, unless PGlite/PostGIS and CereusDB are bundled with the flags above.\n\nA new deploy is picked up via `registerType: \"autoUpdate\"`: the new service worker installs in the background and takes control (`skipWaiting` + `clientsClaim`), so its fresh precache serves subsequent requests. Workbox's default force-reload-on-activation is deliberately suppressed via `onNeedReload` in `src/main.tsx` — on the relative-base `/demo/` subpath that reload fired spuriously and discarded in-progress map state. Page recovery is delegated to `installStaleChunkReload`, which reloads on-demand only when an orphaned lazy chunk 404s (cooldown-guarded; if `sessionStorage` is blocked it skips the reload and lets the preload error surface). Precached chunks are served from cache and never 404, so the page is no longer reloaded out from under the user.\n\n## Container image\n\nThe root Dockerfile packages the browser app: a Node build stage runs the\n`geolibre-desktop` workspace build, then copies `apps/geolibre-desktop/dist`\ninto nginx. The nginx config serves static assets and falls back to `index.html`\nfor browser-entry URLs.\n\nAt startup, the entrypoint validates the selected source policy, applies\nnonblank environment overrides, generates nginx guards, conditionally starts\nthe Python sidecar, and serves the generated public `/deployment.json`. The\nbrowser fetches that policy before first render, then resolves settings from\npolicy, runtime environment and build environment, in that order. See\n[Deployment Policy](deployment-policy.md) and\n[Self-Hosting: container policy enforcement](self-hosting.md#container-policy-enforcement).\n\nThe `Publish Container Image` workflow builds the image for pull requests and\npublishes it to GitHub Container Registry for pushes to `main`, version tags,\nand manual runs. The upstream image name is `ghcr.io/opengeos/geolibre`.\n\nThe image bundles the optional Python sidecar (uvicorn) and reverse-proxies it\nat `/sidecar`; it starts only when the final policy grants `processing:run` or\n`data:add` and `GEOLIBRE_DISABLE_SIDECAR` is not `1`. The container does not\nrun the Tauri desktop shell, so workflows that depend on desktop filesystem\naccess still require the installed desktop app. Client policy and nginx guards\ndo not enforce desktop processing, browser WASM, separately exposed sidecars,\nor plugin execution.\n\n## Security\n\n- The Tauri CSP pins `script-src` to version-specific jsDelivr paths and keeps\n  `connect-src` open to `https:`/`http:` for user-supplied data URLs; see\n  [Desktop CSP `script-src` allowlist](maintenance.md#desktop-csp-script-src-allowlist).\n- File access uses dialog-selected paths only.\n\n### Credential storage\n\nTokens and API keys saved in Settings (share token, Cesium Ion token, Mapbox\ntoken, ArcGIS API key, the secret fields of AI Assistant profiles), saved\nPostGIS connection strings, and the Share.GeoLibre OAuth refresh token live in\ndifferent places per build:\n\n- **Desktop (Tauri, macOS/Windows/Linux):** the OS credential store (macOS\n  Keychain, Windows Credential Manager, Linux Secret Service) under the service\n  `org.geolibre.desktop`, one entry per credential, through the\n  `secure_store_*` commands in `src-tauri/src/secure_store.rs`. The\n  `geolibre.desktopSettings` localStorage blob keeps those fields empty, and\n  `geolibre.postgres.connectionIds` holds only the non-secret order of the\n  `postgres.connection.<uuid>` entries. The index is written before a new\n  entry, so a crash never leaves an unreferenced credential; a startup that\n  finds an indexed id without a credential (its write failed) drops the id and\n  shows the warning. `lib/credential-hydration.ts` loads them into memory\n  before the app renders and migrates any plaintext values left in\n  localStorage by an older build, removing each only after its keychain write\n  succeeds. That load is the only read: `secure_store_get_many` answers once\n  per page load of a webview (the reload hook in `lib.rs` reopens it), and\n  `secure_store_seal`, which the external-plugin loader calls before importing\n  a plugin, closes it without reading, so plugin code cannot pull saved tokens\n  from the store (issue #2858). Writes and deletes stay open. The OAuth refresh token is stored per issuer as\n  `share.oauth.refreshToken.<issuer>` and rotates on every refresh; the access\n  token and the PKCE verifier stay in memory. Because the server revokes a\n  session when a consumed refresh token is presented, the issuer is recorded\n  in the non-secret `geolibre.share.oauth.unsavedIssuers` list before each\n  keychain write and removed once the latest queued write for it completes. A\n  launch that finds its issuer listed deletes the stored token and starts\n  signed out rather than trusting a possibly consumed one. If the list itself\n  cannot be written, the new token is not stored: a delete of the stored copy\n  is queued and the sign-in lasts only until GeoLibre closes. After a refresh\n  that delete is best effort; quitting before it finishes leaves the consumed\n  token stored, so the next launch signs out on its first refresh instead of\n  at startup. Sign-out waits for its delete before reporting signed out, and\n  sends the revoke request whether or not the delete succeeds. If the delete\n  fails, Settings reports the sign-out as incomplete; the delete stays queued\n  and is retried only with a later credential write while the app runs. After\n  a restart the token is discarded if its issuer is on the unsaved list; if\n  that list could not be written either, the token is restored, and it still\n  works only if the revoke request also failed.\n  Credentials a project file would otherwise carry (geocoding API keys\n  `preferences.geocoding.apiKeys` and secret Environment Variables) are\n  stored device-wide by name as `project.geocoding.apiKey.<providerId>` and\n  `project.env.<VARIABLE>`; the non-secret\n  `geolibre.projectCredentials.accounts` list indexes them, with the same\n  index-first write order. Saving a project moves geocoding keys and uniquely\n  named secret environment values to the keychain, leaving empty values in\n  the file. Duplicate or nameless secret environment rows cannot be assigned\n  a unique account and remain for the local keep/strip save prompt.\n  Settings writes a value only when the user changes it. Clearing a field\n  deletes the stored value only if the fi","packages/processing/package.json":"{\n  \"name\": \"@geolibre/processing\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"dependencies\": {\n    \"@bjorn3/browser_wasi_shim\": \"^0.4.2\",\n    \"@geolibre/core\": \"*\",\n    \"@turf/area\": \"^7.4.0\",\n    \"@turf/bbox\": \"^7.4.0\",\n    \"@turf/bearing\": \"^7.4.0\",\n    \"@turf/boolean-contains\": \"^7.4.0\",\n    \"@turf/boolean-intersects\": \"^7.4.0\",\n    \"@turf/boolean-within\": \"^7.4.0\",\n    \"@turf/buffer\": \"^7.4.0\",\n    \"@turf/centroid\": \"^7.4.0\",\n    \"@turf/circle\": \"^7.4.0\",\n    \"@turf/convex\": \"^7.4.0\",\n    \"@turf/destination\": \"^7.3.5\",\n    \"@turf/difference\": \"^7.4.0\",\n    \"@turf/dissolve\": \"^7.4.0\",\n    \"@turf/distance\": \"^7.3.5\",\n    \"@turf/envelope\": \"^7.4.0\",\n    \"@turf/flatten\": \"^7.3.5\",\n    \"@turf/helpers\": \"^7.4.0\",\n    \"@turf/intersect\": \"^7.4.0\",\n    \"@turf/sector\": \"^7.4.0\",\n    \"@turf/simplify\": \"^7.4.0\",\n    \"@turf/tin\": \"^7.4.0\",\n    \"@turf/union\": \"^7.4.0\",\n    \"@turf/voronoi\": \"^7.4.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geolibre-wasm\": \"^1.7.1\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"onnxruntime-web\": \"1.30.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","packages/plugins/package.json":"{\n  \"name\": \"@geolibre/plugins\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./assistant-tool-registry\": \"./src/assistant-tool-registry.ts\",\n    \"./atl03\": \"./src/plugins/atl03.ts\",\n    \"./cog-spectral-profile\": \"./src/plugins/cog-spectral-profile.ts\",\n    \"./earthdata-relay\": \"./src/plugins/earthdata-relay.ts\",\n    \"./elevation-profile-chart\": \"./src/plugins/elevation-profile/chart/profileChart.ts\",\n    \"./geo-editor-geometry\": \"./src/plugins/geo-editor-geometry.ts\",\n    \"./lidar\": \"./src/plugins/components/lidar.ts\",\n    \"./local-netcdf\": \"./src/plugins/local-netcdf.ts\",\n    \"./maplibre-graticule\": \"./src/plugins/maplibre-graticule.ts\",\n    \"./maplibre-vantor/style.css\": \"./src/plugins/vantor/styles.css\",\n    \"./mgrs-reference\": \"./src/plugins/mgrs-reference.ts\",\n    \"./plugin-ids\": \"./src/plugin-ids.ts\",\n    \"./raster-symbology\": \"./src/plugins/raster-symbology.ts\",\n    \"./right-panel-registry\": \"./src/right-panel-registry.ts\",\n    \"./zarr-time-axis\": \"./src/plugins/zarr-time-axis.ts\"\n  },\n  \"dependencies\": {\n    \"@carbonplan/zarr-layer\": \"0.11.0\",\n    \"@deck.gl/aggregation-layers\": \"^9.4.0\",\n    \"@deck.gl/core\": \"^9.4.0\",\n    \"@deck.gl/geo-layers\": \"^9.4.0\",\n    \"@deck.gl/layers\": \"^9.4.0\",\n    \"@deck.gl/mapbox\": \"^9.4.0\",\n    \"@deck.gl/maplibre\": \"^9.4.0\",\n    \"@deck.gl/mesh-layers\": \"^9.4.0\",\n    \"@developmentseed/deck.gl-geotiff\": \"^0.8.1\",\n    \"@developmentseed/deck.gl-raster\": \"^0.8.1\",\n    \"@developmentseed/morecantile\": \"^0.8.1\",\n    \"@dvt3d/maplibre-three-plugin\": \"^1.7.1\",\n    \"@esri/maplibre-arcgis\": \"^1.3.1\",\n    \"@geolibre/core\": \"*\",\n    \"@geolibre/map\": \"*\",\n    \"@geoman-io/maplibre-geoman-free\": \"^0.10.1\",\n    \"@loaders.gl/i3s\": \"^4.5.4\",\n    \"@luma.gl/core\": \"^9.4.2\",\n    \"@luma.gl/engine\": \"^9.4.2\",\n    \"@luma.gl/webgl\": \"^9.4.2\",\n    \"@mapbox/vector-tile\": \"^3.0.0\",\n    \"@maplibre/maplibre-gl-directions\": \"^0.10.1\",\n    \"@strands-agents/sdk\": \"^1.21.0\",\n    \"@tauri-apps/api\": \"^2.12.4\",\n    \"@tauri-apps/plugin-opener\": \"^2.7.2\",\n    \"@turf/union\": \"^7.4.0\",\n    \"a5-js\": \"^1.0.0\",\n    \"cog-tiler-wasm\": \"^0.5.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"h3-js\": \"^4.5.0\",\n    \"h5wasm\": \"^0.10.3\",\n    \"hyparquet\": \"^1.31.4\",\n    \"hyparquet-compressors\": \"^1.1.2\",\n    \"icechunk-js\": \"^0.6.1\",\n    \"jspdf\": \"^4.2.1\",\n    \"lerc\": \"^4.2.0\",\n    \"mapillary-js\": \"^4.1.2\",\n    \"maplibre-gl\": \"^6.15.0\",\n    \"maplibre-gl-3d-tiles\": \"^0.5.11\",\n    \"maplibre-gl-basemap-control\": \"^0.14.4\",\n    \"maplibre-gl-components\": \"^0.31.2\",\n    \"maplibre-gl-duckdb\": \"^0.2.4\",\n    \"maplibre-gl-earth-engine\": \"^0.4.2\",\n    \"maplibre-gl-enviroatlas\": \"^0.1.1\",\n    \"maplibre-gl-esri-wayback\": \"^0.2.2\",\n    \"maplibre-gl-fema-wms\": \"^0.1.2\",\n    \"maplibre-gl-geo-editor\": \"^0.11.2\",\n    \"maplibre-gl-geoagent\": \"^0.6.1\",\n    \"maplibre-gl-lidar\": \"^0.21.0\",\n    \"maplibre-gl-nasa-earthdata\": \"^0.1.4\",\n    \"maplibre-gl-national-map\": \"^0.1.1\",\n    \"maplibre-gl-overture-maps\": \"^0.4.1\",\n    \"maplibre-gl-planetary-computer\": \"^0.4.0\",\n    \"maplibre-gl-raster\": \"^0.14.15\",\n    \"maplibre-gl-splat\": \"^0.2.9\",\n    \"maplibre-gl-streetview\": \"^0.8.0\",\n    \"maplibre-gl-swipe\": \"^0.14.0\",\n    \"maplibre-gl-time-slider\": \"^1.8.7\",\n    \"maplibre-gl-usgs-lidar\": \"^0.11.5\",\n    \"maplibre-gl-vector\": \"^0.11.1\",\n    \"mgrs\": \"^2.2.0\",\n    \"netcdfjs\": \"^4.0.0\",\n    \"ngeohash\": \"^0.6.4\",\n    \"open-location-code-typescript\": \"^1.5.0\",\n    \"pbf\": \"^5.1.2\",\n    \"pmtiles\": \"^4.5.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\",\n    \"satellite.js\": \"^7.1.0\",\n    \"shpjs\": \"^6.2.0\",\n    \"three\": \"^0.186.1\",\n    \"webdggrid\": \"^1.9.3\",\n    \"whitebox-wasm\": \"^0.6.1\",\n    \"zarrita\": \"^0.7.5\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"@types/ngeohash\": \"^0.6.8\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","packages/processing/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","packages/plugins/tsconfig.json":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"paths\": {}\n  },\n  \"include\": [\"src\"]\n}\n","packages/map/package.json":"# Map Skill\n\nPath: `packages/map/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/package.json)","packages/map/tsconfig.json":"# Map Skill\n\nPath: `packages/map/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/tsconfig.json)","packages/embed/package.json":"# Embed Skill\n\nPath: `packages/embed/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/package.json)","packages/embed/README.md":"# embed Documentation\n\nPath: `packages/embed/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/README.md)","packages/embed/tsconfig.build.json":"# Embed Skill\n\nPath: `packages/embed/tsconfig.build.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.build.json)","packages/embed/tsconfig.json":"# Embed Skill\n\nPath: `packages/embed/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.json)","packages/core/package.json":"# Core Skill\n\nPath: `packages/core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/package.json)","packages/core/tsconfig.json":"# Core Skill\n\nPath: `packages/core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/tsconfig.json)","packages/collab-core/package.json":"# Collab-core Skill\n\nPath: `packages/collab-core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/package.json)","packages/collab-core/tsconfig.json":"# Collab-core Skill\n\nPath: `packages/collab-core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/tsconfig.json)","apps/geolibre-desktop/public/plugins/README.md":"# plugins Documentation\n\nPath: `apps/geolibre-desktop/public/plugins/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/apps/geolibre-desktop/public/plugins/README.md)"},"items":[{"name":"architecture.md","path":"docs/architecture.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/docs/architecture.md","title":"docs - System Architecture & Module Boundaries","category":"project-spec","format":"markdown","content":"# GeoLibre Architecture\n\n## Overview\n\nGeoLibre is a free and open-source, lightweight, cloud-native GIS platform that runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks, all from a single npm workspaces monorepo. The UI is a React app that ships as a native desktop app hosted by Tauri v2 and as a browser-based web app, adapting responsively to mobile and small screens. Four engines can render a map: MapLibre GL JS (the default), Mapbox GL JS, CesiumJS, and the ArcGIS Maps SDK for JavaScript. deck.gl supplies advanced raster, point-cloud, and 3D overlays where an engine supports them. Application state lives in a Zustand store (`@geolibre/core`). See the user-facing [rendering-engine guide](user-guide/rendering-engines.md) and the detailed [MapLibre](maplibre-renderer.md), [Mapbox](mapbox-renderer.md), [Cesium](cesium-renderer.md), and [ArcGIS](arcgis-renderer.md) references.\n\n```mermaid\nflowchart LR\n  UI[React UI] --> Store[Zustand Store]\n  Store --> Engine[MapEngine interface]\n  Engine --> ML[MapLibre GL JS]\n  Engine --> MB[Mapbox GL JS]\n  Engine --> CZ[CesiumJS]\n  Engine --> AG[ArcGIS Maps SDK]\n  Plugins[Built-in plugins] --> Store\n  Plugins --> Engine\n  UI --> Tauri[Tauri Plugins]\n  Tauri --> FS[File System]\n  Proc[Processing] --> Store\n  Proc -.-> Sidecar[FastAPI optional]\n```\n\n## Packages\n\n| Package                | Responsibility                                                                                 |\n| ---------------------- | ---------------------------------------------------------------------------------------------- |\n| `@geolibre/core`       | Domain types, project JSON schema, global store                                                |\n| `@geolibre/map`        | Rendering-engine lifecycles and adapters, layer sync, GeoJSON, raster, tile, MBTiles, controls, and selection styling |\n| `@geolibre/ui`         | Shared UI primitives (shadcn-style)                                                            |\n| `@geolibre/processing` | Client-side algorithm registry                                                                 |\n| `@geolibre/plugins`    | Plugin interface and built-in plugins                                                          |\n| `@geolibre/embed`      | Typed, dependency-free client for the iframe embed API, published to npm                       |\n| `@geolibre/collab-core` | Transport-neutral collaboration relay core (wire protocol, permissions, payload validators) shared by the `workers/collab` and `workers/collab-node` relays |\n| `geolibre-desktop`     | Shell layout, Tauri I/O, composition                                                           |\n\n## State flow\n\n1. User adds data through the Add Data menu, the Tauri dialog, browser file picker, drag and drop, or a built-in plugin control.\n2. Local vector data is parsed directly or converted to GeoJSON with DuckDB-WASM Spatial, then passed to `addGeoJsonLayer` in the store.\n3. Tile, service, raster, ArcGIS, MBTiles, and plugin-backed layers create `GeoLibreLayer` records with source metadata and native MapLibre layer ids when applicable.\n4. The selected `MapEngine` subscribes to `layers` and reconciles them with its SDK. MapLibre uses `MapController.syncLayers`; Mapbox, Cesium, and ArcGIS use their engine-specific adapters. The layer-control glue shared by the two Style-Spec engines lives in `packages/map/src/layer-control-host.ts`.\n5. Style panel and layer panel updates change layer state, then map sync updates paint, visibility, opacity, ordering, and removal.\n6. Attribute table selections update the highlighted feature source and can zoom the map to the selected feature.\n7. Desktop save uses `projectFromStore` and writes `.geolibre` to disk. The earlier `.geolibre.json` name remains readable.\n\nEvery layer action replaces the `layers` array, so `useAppStore((s) => s.layers)` re-renders a component on any edit of any layer. Only subscribers that genuinely need every field of every layer (the map canvases' sync, the Layers panel) should do that. Everything else uses the narrow hooks in `packages/core/src/layer-selectors.ts`: `useLayer(id)` for one record, `useLayerIds()` for the id list, `useLayerSummaries()` for `{ id, name, type, visible, groupId }` rows, and `useLayersWhen(open)` for always-mounted dialogs and panels that need the full array only while open.\n\n`useAppStore` is one Zustand store, but its code is split into slices under `packages/core/src/store/` (project, map view, layers, layer groups, project content, processing, libraries, session, collaboration, capabilities, and the `ui` dialog flags), composed in `packages/core/src/store.ts`. Add a new field or action to the slice that owns its data; an action that must also update another slice's fields (as `removeLayer` scrubs references everywhere) reads and writes the whole state through `set`/`get`. Undo history (`store/undo-history.ts`) tracks only the fields `partializeHistory` lists (layers, layer groups, basemap, story map, comments, dashboard widgets, legend). Everything else, including the whole `ui` sub-state, the selection and the camera, never records an undo step and is never reverted by undo or redo. Secondary panes and the Print Layout also reference layers but stay untracked (panes carry their own camera and must match the untracked grid layout; the composer writes back a local copy), so each undo/redo step scrubs their references to layers the step removed.\n\n## Rendering-engine model\n\nThe Zustand store is renderer-neutral: it contains plain `GeoLibreLayer`\nrecords, preferences, and `MapViewState` values rather than objects owned by a\nmapping SDK. MapLibre, Mapbox, Cesium, and ArcGIS each implement the shared\n`MapEngine` interface for camera operations, capability checks, picking,\ncontrols, placement, extent drawing, and capture. `primaryRenderer` selects the\nmain canvas, while each `SecondaryMapView.viewKind` selects the engine for a\nsplit pane. Changing either value unmounts the previous engine and connects the\nreplacement to the same project state.\n\nCapabilities are explicit rather than assumed. The shell gates commands against\nthe active `MapEngine`; layers report engine compatibility; and plugins declare\nan `engines` list. This lets a project retain an unsupported layer or suspended\nplugin across a switch without asking every SDK to emulate every feature.\n\nInside `packages/map/src`, a file named for an engine (`arcgis-*`, `cesium-*`,\n`mapbox-*`, and MapLibre's `layer-sync`, `map-controller`, and `MapCanvas`)\nnever imports another engine's file. Code two engines share lives in a neutral\nmodule named for what it does (`pmtiles-archive.ts`, `protocol-tiles.ts`,\n`gl-style-compiler.ts`, `feature-style.ts`, and so on). The\n`local/no-cross-engine-imports` ESLint rule\n(`eslint-rules/no-cross-engine-imports.mjs`) enforces the boundary and lists\nthe classification.\n\n## 3D globe view (CesiumJS)\n\nThe default MapLibre map can be joined by — or replaced with — a 3D globe rendered with [CesiumJS](https://cesium.com/platform/cesiumjs/). Cesium subscribes to the same engine-neutral store described above, whether it owns the primary canvas or one pane in a mixed-engine grid. MapLibre remains the default; integrations that paint directly through the MapLibre style API still require MapLibre.\n\n- **Where it renders.** Two places, both drawing the same store state:\n  - **As a secondary pane.** Each pane in the map grid carries a 2D/3D toggle (`SecondaryMapView.viewKind`), so a globe sits beside the 2D map for comparison.\n  - **As the primary map** (issue #2217). **View → Rendering engine → Cesium** selects the globe through the store's `primaryRenderer`. `DesktopShell` mounts `PrimaryCesiumCanvas` in place of the previous engine's canvas, so that renderer is not merely hidden — it is unmounted and stops consuming GPU resources. Switching adds or removes no panes, and a multi-pane grid can still mix all four engines. `CesiumCanvas` distinguishes the two roles by whether it is given a `viewId`: with one it is a pane backed by a `secondaryMapViews` record (own camera, own visibility overrides); without one it _is_ the primary map, reading and writing the shared `mapView` directly and ignoring the `syncView` toggle (which exists to make panes follow the primary camera). Because every engine reads the same store, the camera, basemap, layers, groups, visibility, and opacity survive the switch untouched where supported.\n- **What the globe cannot do (yet).** Every tool that drives a `MapController` — the map context menu, legend, comments, story map, terrain settings, the ML panels, and the MapLibre-typed plugin API — is MapLibre-only. Rather than leave them broken, `DesktopShell` does not mount them while the globe owns the primary area, and the View menu greys out the items that drive a controller (zoom, viewport history, Reset Orientation, Set View, the Google Maps/Earth hand-offs) instead of letting them silently do nothing. Renderer-neutral, store-driven surfaces (the Layers panel, Style Manager, the attribute table, processing, the Dashboard) stay available. Layer kinds the globe cannot draw stay in the project and are tagged \"2D only\" in the Layers panel, and come back when MapLibre does. A renderer-neutral controller interface is the follow-up milestone in #2217.\n- **Enabling it.** The 2D/3D pane toggle and the Rendering engine menu are both offered whether or not a Cesium Ion token is configured — the globe draws the project basemap, which needs no token. A token adds Cesium World Terrain and supplies Ion World Imagery as the fallback for a basemap with no raster form; without one the view shows a one-line hint saying so. The token is resolved through `getCesiumIonToken()` (`@geolibre/core`), which reads `VITE_CESIUM_TOKEN`/`CESIUM_TOKEN` from the build **or** from a runtime override, so it can be set at build time (`CESIUM_TOKEN`; see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion)) or at runtime with no rebuild. Settings → Environment Variables has a dedicated masked **Cesium Ion token** field backed by device-local `DesktopSettings` (the OS credential store on desktop, localStorage on the web; never the shared project file); `useRuntimeEnvironmentVariables` projects it into `VITE_CESIUM_TOKEN` on the `window.__GEOLIBRE_RUNTIME_ENV__` global (empty values are not projected, so they cannot blank a build-time token). The shared `useCesiumIonToken()` hook re-resolves the token on the `geolibre:runtime-env-change` event and both mount sites key the globe on it, so a newly entered token takes effect without a reload.\n- **Basemap.** `basemapToCesiumImagery()` (`@geolibre/core`) translates the project's `basemapStyleUrl` into a plain descriptor of what the globe should draw, and `applyBasemapImagery()` (`packages/map/src/cesium-basemap.ts`) turns that into imagery layers. The split keeps the decision engine-free and beside the basemap catalogs it reads, so the 2D and 3D renderers share one source of truth. Planetary and regional basemaps are already raster tile sets and carry straight over (TMS sources get `{y}` swapped for Cesium's `{reverseY}`, and a hybrid basemap's labels overlay rides directly above its imagery); GeoLibre's own vector styles map to a keyless raster basemap of matching tone, so a dark project basemap gives a dark globe; the blank basemap leaves a bare ellipsoid. Anything else — a provider style, a custom URL, an offline archive — has no raster form, and the renderer falls back to Ion World Imagery when a token is configured and OpenStreetMap when not. The basemap layers are inserted at the bottom of the imagery stack (index 0, the overlay at 1), which is what keeps them below the data layers `CesiumLayerSync` appends and raises to the top.\n- **Lazy loading.** The whole Cesium engine (~4.8 MB) is `import()`-ed only when a globe first mounts, kept in its own build chunk (`CODE_SPLITTING_GROUPS` in `vite.config.ts`) and off the 2D boot path. The build fails if Cesium, or more than 3 MB of JS, ends up in the app entry's static import graph (`bootBundleBudgetPlugin`). A Vite plugin (`vite-plugins/copy-cesium-assets.ts`) stages Cesium's runtime Workers/Assets/Widgets into `public/cesium/` and the canvas sets `window.CESIUM_BASE_URL` so the engine finds them.\n- **Drawing.** Both engines implement `drawExtent` with a shared pointer lifecycle and geographic extents. Cesium uses a draggable entity for manual placement and ground-clamped rectangles for extraction previews. Escape, pointer cancellation, focus loss, and renderer teardown restore navigation. The raster-subset panel uses these operations on both renderers; crossing extents retain west > east so the extractor can explain that they must be split. The basemap extraction panel shares the drawing helper, while its menu remains gated on vector-style support.\n- **Camera sync.** `packages/map/src/cesium-camera.ts` converts between MapLibre's Web-Mercator `MapViewState` (zoom, nadir-referenced pitch, bearing) and Cesium's camera (metric range, horizon-referenced pitch, heading), matched by **ground resolution** (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. `CesiumCanvas` seeds its camera from the shared `mapView`, applies store changes to the globe, and writes the globe's own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→`moveEnd` echo so there is no jitter loop.\n- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: \"cluster\"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package and reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285), streamed by view like EPT below (issue #2261), and plain LAS/LAZ files (issue #2261), which have no octree to sample and so are downloaded whole (up to 512 MB, decoded size included), decoded with the same package's LAS reader, thinned to every n-th point within the same budget, and reprojected through their GeoTIFF GeoKeys or, failing those, their WKT or the one the LiDAR control recorded. A compound WKT (a projected CRS paired with a vertical one, which proj4 cannot parse) is reduced to its horizontal part, and heights are converted to metres from the vertical unit, else the projected CRS's linear unit, the LAS convention for state-plane clouds in feet. A cloud with no CRS at all is refused rather than read as degrees. COPC archives and Entwine Point Tile datasets (`ept.json`, issue #2261), which run to tens of billions of points for a USGS 3DEP county, are streamed by view rather than sampled once (`cesium-point-cloud-stream.ts`, fed by `openCopcSource` and `openEptSource`; COPC's octree cube and root spacing give the same geometry EPT's manifest states): when the camera settles, the octree nodes that intersect the view are chosen most-needed first, by how coarse each node's point spacing looks at its distance from the camera, so a tilted view refines the ground underfoot before the horizon; the root always stays as the cloud's outline, a refresh only drops old nodes once the new ones are shown, decoded nodes are cached (3 million points) so panning back is free, and at most 1.5 million points are held as primitives. Subtree hierarchy files load when the walk reaches them, LASzip nodes (64 MB cap each) are fetched four at a time and decoded with the same LAS reader, and the CRS comes from the manifest's EPSG code (through the GeoKey path, for its unit) or its WKT, with an inverse projection to find which nodes a view covers; `binary` and `zstandard` datasets are refused. All three decoders drop ASPRS noise (classes 7 and 18) and stretch the height ramp over the 1st to 99th percentile of the decoded heights, so a few outliers do not flatten the colours — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. Zarr layers draw through zarr-cesium's `ZarrLayerProvider` (`cesium-zarr-imagery.ts`, issue #2261), fed from the same record the 2D Zarr control and the ArcGIS renderer read (the URL, a registered store or kerchunk manifest, the variable, selector, colour limits, and ramp); a Time Slider step or restyle updates the live provider and swaps in a fresh `ImageryLayer` over it rather than reopening the store, and a grid in a CRS other than EPSG:4326/3857 is reported as a layer error. zarr-cesium imports the `cesium` wrapper, which Vite aliases to `@cesium/engine` so the globe keeps one engine instance. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles, and ArcGIS vector tiles with a resolved style) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. ArcGIS vector-tile imports persist their resolved sources and prefixed style layers, allowing the headless sync to recreate them after a renderer switch or project reload and apply visibility, filters, and opacity while preserving service colors; the Style panel offers only opacity, order, zoom range, and filters for those layers rather than color editors that would not reach them. Older ArcGIS records without that resolved style remain 2D-only and must be re-added. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, legacy `arcgis` records without a resolved style, local point-cloud files the LiDAR control read from disk (no URL to fetch), EPT datasets in `binary` or `zstandard` form, raw `.ply`/`.splat` splat files, and the screen-space deck.gl viz kinds (heatmap, screen grid, contour) are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those \"2D only\". Deck.gl Layer builder records have no deck.gl on the globe; `cesium-deck-viz.ts` (issue #2261) rewrites each one, memoized per record and per data array so an opacity drag reuses the data source, into a record the globe already draws: scatterplot, icon, and text rows become points (fixed-size dots, pin markers, or labels), arcs become 3D parabolas over the great circle while lines and great circles stay on the ground, trips draw their whole paths without the trail animation, hexagon and grid rows are binned in JS into polygons coloured from the 2D ramp and extruded by the same 0..1000 m elevation range times the elevation scale, a GeoJSON viz passes through with its extrusion property, and glTF model rows become a CZML document of `model` packets with their altitude, bearing, and scale. The Style panel's colour and line width reach the globe the way the 2D overlay bridges them. Native KML/KMZ documents use `KmlDataSource`, preserving document styling, network links, and overlays; visibility and opacity are applied without replacing the original color properties. Local KMZ archives persist as data URLs. The Elevation Profile plugin draws native entities and samples the active terrain provider, rejecting results when that provider changes.\n- **Globe interaction parity.** The primary globe publishes ground cursor coordinates and optional terrain elevation to the shared status bar, clearing them on pointer exit and teardown. Projected scene modes report a Mercator projection. Home, scene-mode, and fullscreen widgets participate in shared control positioning without remounting their DOM. In the Controls menu they answer to the existing entries: **Compass** governs the Home (reset view) button and **Globe** governs the scene-mode picker, as the globe's counterparts of the 2D reset-bearing and projection controls, and the toolbar replays those choices onto a freshly mounted globe.\n- **Environment plugins.** The Sun simulation, Atmospheric Effects, Clouds, Precipitation, and Flight Simulator plugins declare Cesium alongside the 2D engines (Sun, Effects, and the Flight Simulator list MapLibre, Mapbox, and Cesium; Clouds and Precipitation also list ArcGIS) and branch on `app.getCesiumScene()` (issue #2287), a typed handle to the primary globe's namespace, widget, scene, camera, and clock that `CesiumEngine.getCesiumScene()` exposes as the globe's counterpart to `getMap()`. On the globe the Sun drives a native `SunLight`, `globe.enableLighting`, and the scene clock (the night-side depth maps onto `globe.vertexShadowDarkness`); Effects toggles the sky box and atmosphere and re-tints `SkyAtmosphere` by hue/saturation/brightness shifts from the halo settings; the Flight Simulator shares its keyboard, physics, and HUD with both 2D maps behind a camera adapter — `camera.setView` each frame with `screenSpaceCameraController.enableInputs` suspended on the globe, `setFreeCameraOptions` on Mapbox, `calculateCameraOptionsFromCameraLngLatAltRotation` on MapLibre; the weather overlays are store tile layers and render through `CesiumLayerSync` unchanged. The Spinning Globe control needs no branch: it drives the store through the control host's MapLibre facade. `DesktopShell` re-attaches these plugins after either engine mounts, so a renderer swap rebinds them the way a MapLibre re-init does.\n- **Plugin controls.** `CesiumControlHost` (`packages/map/src/cesium-control-host.ts`) hands a plugin control the same recording map the ArcGIS host does (issue #3088): `createShadowStyle` records its Style Spec calls, a store layer that mirrors one of its layers (`controlLayerMirrors`, including MapLibre's derived `layer-<id>-fill` ids) is drawn by `CesiumLayerSync`, and every other GeoJSON layer is evaluated by `shadowOverlayGraphics` (`shadow-overlay.ts`) and drawn as ground-clamped entities by `cesium-control-overlay.ts` in a host-owned `CustomDataSource`, with rhumb arcs so a straight 2D segment stays straight and points and labels past the horizon hidden. Layer-scoped events come from the shared `control-layer-events.ts`. Custom layers throw, so a control that needs MapLibre's WebGL context fails to mount rather than mounting dark; the host's COG helpers import rasters as store `cog` records instead (`native-raster-import.ts`). Because Cesium cancels `pointerdown`, the host re-dispatches the `mousedown`/`mousemove`/`mouseup` events the browser then drops, for controls that draw by dragging. `getControlMap(app)` resolves this map through `getCesiumControlMap`, and plugins still opt in with `engines`.\n- **Persistence.** A pane's `viewKind` is part of `SecondaryMapView` and round-trips through the `.geolibre.json` project format (`normalizeSecondaryMapViews`), so a project saved with a globe pane reopens as the globe, token or not. The workspace's own choice persists as the top-level `primaryRenderer` (`normalizePrimaryRenderer`), written only when it is not the default — so a MapLibre project is byte-identical to one saved before the setting existed, and a 3D-first project reopens directly on the globe.\n\n## DuckDB-WASM\n\nVector file import uses DuckDB-WASM for formats that need conversion before MapLibre can render them:\n\n```sql\nINSTALL spatial;\nLOAD spatial;\n```\n\nGeoParquet is read with DuckDB's Parquet reader after loading Spatial. Other local vector formats are passed to Spatial `ST_Read` when the WebAssembly extension can load the GDAL-backed reader. Zipped Shapefiles are parsed with `shpjs` first, then DuckDB Spatial is tried if that parser cannot read the file. KML files (and the KML inside unzipped KMZ archives) are read by an in-house parser that preserves embedded symbology, emitting [simplestyle-spec](https://github.com/mapbox/simplestyle-spec) properties (`fill`, `stroke`, `stroke-width`, and so on) so styled KML renders the way it does in Google Earth; KML the parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. A vector layer whose features carry simplestyle properties is rendered per-feature: paint expressions read the per-feature color/width and fall back to the flat layer style.\n\n### Apache Iceberg\n\nIceberg tables are read on the same engine, with DuckDB's core `iceberg` extension loaded alongside Spatial (`INSTALL iceberg; LOAD iceberg;`). A table addressed by its metadata location is scanned with `iceberg_scan()`; an Iceberg REST catalog is `ATTACH`ed under a fixed alias, enumerated with `SHOW ALL TABLES`, and the chosen table read as a qualified name. Either generated statement is only a default: the dialog pre-fills it into a SQL box, and whatever is in that box becomes the scan's source, so a `WHERE`, a join, or a projection can decide which geometries are rendered. A custom statement is checked with `cleanStatement`/`containsMultipleStatements` (the literal-aware helpers in `sql-workspace.ts`) before being wrapped as a sub-select, so a pasted script fails with a clear message rather than a parse error pointing at the generated wrapper.\n\nGeometry handling is deliberately narrower than the vector-file loader's. Only columns DuckDB reports as native `GEOMETRY` are candidates: Iceberg v3 has a real geometry type — `iceberg_column_definition.cpp` maps it to `LogicalType::GEOMETRY` — so a BLOB or VARCHAR here is an ordinary attribute, and the blob/base64-WKB fallbacks that make sense for plain Parquet would only produce loads that fail inside `ST_AsGeoJSON`. **The CRS comes from the schema, not the user.** DuckDB renders a CRS-annotated column as `GEOMETRY(<crs>)`, so `icebergTransformCrs` reads it and reprojects to WGS84 through `ST_Transform`; a column carrying no CRS parameter is `OGC:CRS84` by specification (the same constant DuckDB's extension hard-codes as `IcebergConstants::DefaultGeometryCRS`), which is already GeoJSON's lon/lat convention and passes through untransformed. Nothing about the coordinate system is persisted on the layer, so a table re-projected upstream stays correct on the next reload.\n\nThe work is split between `lib/iceberg.ts`, which holds the persisted layer config, the SQL builders, and the table-selection rule with no DuckDB dependency, and `lib/iceberg-loader.ts`, which owns the engine. The loader runs on the **SQL Workspace's** dedicated DuckDB instance rather than the shared one: an Iceberg scan reads the table's Parquet data files over HTTP, so it needs that instance's pre-spatial warm-up and its poisoned-instance recovery (see `resetSqlDatabase`).\n\nTwo consequences of Iceberg tables being large are wired into the app rather than left to the user. Every load is capped by a row limit, and the total row count — a `count(*)` over the configured source, which DuckDB can answer from the manifest metadata for an unfiltered table, though a custom statement's filters, joins, or projections have to be evaluated — is shown before anything is materialized. And the resulting layer, though refreshable on demand, is excluded from timer-driven refresh by `supportsAutoRefresh` in `lib/layer-refresh.ts` — re-scanning a table of that size on an interval is never what the user meant, so the automatic-refresh interval is unavailable for it rather than merely defaulted off.\n\n## Advanced Add Data workflows\n\nThe Add Data surface includes native dialogs for XYZ, WMS, WFS, vector files (via the Add Vector dialog backed by `maplibre-gl-vector`), GeoJSON URLs, vector tile sources, delimited text, raster tile templates, COG and GeoTIFF rasters (via the Add Raster dialog backed by `maplibre-gl-raster`), MBTiles, ArcGIS FeatureServer or VectorTileServer layers, Apache Iceberg tables, and GPX waypoints, tracks, and routes. It also supports 3D Tiles layers, Cesium Ion assets by id (a tileset or imagery layer with `metadata.sourceKind: \"cesium-ion\"`, offered when the globe is the primary renderer and badged \"3D only\" on the 2D map; `packages/core/src/cesium-ion.ts`, issue #2290), WFS and GeoJSON URL refresh, text marker labels, and multiple DuckDB SQL query-result layers with identify, selection, and attribute table support. The Components plugin wraps `maplibre-gl-components` panels for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, then mirrors added layers into the GeoLibre store so the Layer panel, project format, and layer control can reason about them. Additional data sources are available through the Planetary Computer and Earth Engine panels, the Overture Maps plugin, and the federal Web Services plugins. The Time Slider plugin, backed by `maplibre-gl-time-slider`, animates time series raster and vector data (COG, XYZ/WMTS, WMS-Time, and time-filtered GeoJSON) through a docked timeline, mirroring each source it adds into the GeoLibre store as an external native layer.\n\nLocal MBTiles tiles are read through a custom MapLibre protocol backed by Tauri commands. Remote rasters are fetched through the desktop backend when needed, and the local development server includes a raster proxy for selected release assets that need CORS handling.\n\n## Python sidecar\n\nThe FastAPI app in `backend/geolibre_server` backs the format Conversion tools, the raster tools, and the optional server engine for the Whitebox toolbox through a managed local processing sidecar. The desktop app starts the sidecar on demand, communicates over `127.0.0.1`, and keeps the heavier Python processing stack outside the browser bundle.\n\nThe Whitebox toolbox does **not** depend on it: its 1,000+ tools run in the browser on the `geolibre-wasm` WebAssembly runtime by default, and the sidecar is an opt-in alternative engine that can read native file paths for batch runs over a directory.\n\nThe Vector tools (Processing → GeoLibre Toolbox → Vector) run client-side with Turf.js and need no sidecar. All of the tools can optionally run on the sidecar's `/vector` endpoints, backed by GeoPandas and Shapely, for projection-aware results; the sidecar reports availability through `/vector/status`, and the dialog falls back to the client engine when the optional `vector` extra is not installed.\n\nA third Vector engine, **Python (Pyodide)**, runs the same GeoPandas/Shapely code **in the browser** via [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly), so the GeoPandas path is available on the web build with no server. The geometry logic is a framework-free module, `backend/geolibre_server/geolibre_server/vector_ops.py`, that both the sidecar and the browser run — a Vite plugin (`vite-plugins/copy-vector-ops.ts`) copies it into the app bundle, and a classic Web Worker (`public/pyodide/pyodide-worker.js`) loads Pyodide from a CDN, installs `geopandas`, and calls `run_vector_tool` over a JSON-string boundary. One source of truth means the Sidecar and Pyodide engines return identical results. The Pyodide runtime is downloaded lazily on first use; the `VITE_PYODIDE_INDEX_URL` env var points it at a self-hosted mirror for offline/production deployments.\n\nThe **SQL Workspace** offers an **Apache Sedona** engine alongside DuckDB and PostGIS. It runs Sedona spatial SQL on [SedonaDB](https://sedona.apache.org/sedonadb/), the single-node Rust (DataFusion + Arrow) engine, through the sidecar's `/sql` endpoints (`/sql/status`, `/sql/run`) backed by the optional `sedona` extra (`apache-sedona[db]`). In the browser — and on desktop when the extra is not installed — the same engine runs entirely client-side on [CereusDB](https://github.com/tobilg/cereusdb), a WebAssembly build of SedonaDB; the workspace prefers the sidecar when reachable and falls back to CereusDB automatically. The CereusDB bundle is large, so it is dynamically imported into its own chunk and only downloaded when a Sedona query first runs.\n\nThe sidecar has since grown past conversion. Its routers live in `backend/geolibre_server/geolibre_server/app/`: `raster.py` runs the Raster toolbox on rasterio/numpy/contourpy, `vector.py` backs the optional GeoPandas vector engine, `sql.py` backs the Sedona engine above, `postgis.py` and `mssql.py` load PostGIS and SQL Server tables as editable layers and write edits back, `pointcloud.py` writes point cloud annotator labels into a whole LAS/LAZ/COPC file, and `ml.py` reverse-proxies AI Segmentation to the `samgeo-api` server (SamGeo/SAM 3), which is kept out of process because SAM 3 needs a multi-gigabyte GPU Torch build. See the [Server API reference](server-api.md) for every endpoint. Leafmap and GeoAI workflows are still expected to follow the same pattern.\n\n## Offline support (PWA)\n\nThe standalone web build is an installable Progressive Web App. `vite-plugin-pwa` (configured in `apps/geolibre-desktop/vite.config.ts`) emits a web manifest plus a Workbox service worker, and `src/main.tsx` registers it next to `installStaleChunkReload` so the two coordinate. The service worker is built only for the web build; it is disabled for the Tauri desktop build (already offline via bundled assets) and the embedded Jupyter wheel (`GEOLIBRE_EMBED=1`), where `registerSW` resolves to a no-op.\n\nCaching is split to keep the first visit light:\n\n- **Precache (app shell).** The HTML and the JS/CSS chunks that boot the map are precached, so the shell loads with no network after the first visit. The heavy chunks below are excluded from the precache to avoid a large first-load download.\n- **Runtime cache, CacheFirst.** The content-hashed build assets the precache skips (everything under `/assets/`) are cached on first use: the **MapLibre** bundle, **DuckDB-WASM and its spatial extension**, and the MapLibre feature-plugin chunks. Hashed filenames make CacheFirst safe — a redeploy mints new URLs, so a stale entry is never served as current. This is what makes local-file workflows (DuckDB Spatial conversion) work offline after they have run online once. Self-hosting the spatial extension via `VITE_DUCKDB_SPATIAL_EXTENSION_PATH` keeps it same-origin so it is cached too. **PGlite/PostGIS** is not in this same-origin list: it is fetched from the jsDelivr CDN to keep it out of the build — ~25 MB raw, and ~22 MB of otherwise-incompressible weight in the Tauri binary — so the PostGIS SQL engine needs network on first use. A separate rule caches it thereafter (see below).\n- **Runtime cache, CDN engines.** A second CacheFirst rule (`geolibre-cdn-engines`) covers the heavy engines served from jsDelivr: **Pyodide** and its wheels, **PGlite/PostGIS**, the **CereusDB** (Apache Sedona) wasm, and `gdal3.js`. Their URLs embed the exact package version, so a redeploy mints new URLs and CacheFirst never serves a stale engine, and jsDelivr's permissive CORS headers mean these come back as normal (non-opaque) 200s that can be revalidated and evicted like any other entry. This is what lets the browser SQL (PostGIS), Sedona SQL, and Python features work offline **after their first online use**. When `GEOLIBRE_PGLITE_CDN=0` / `GEOLIBRE_CEREUS_CDN=0` those engines are bundled under `/assets/` instead and this rule simply never matches them; Pyodide is always CDN-loaded regardless. The rule also covers `/npm/@duckdb/`, which matches when `GEOLIBRE_DUCKDB_WASM_CDN=1` moves DuckDB off the origin (below) and when `maplibre-gl-duckdb` fetches its own engine, which it does from jsDelivr in every build.\n- **Basemaps.** Tiles and styles from the CORS-friendly default hosts (OpenFreeMap, CARTO) are runtime-cached. Other remote tiles, services, and ArcGIS/WMS/WFS sources stay network-only by design and are unavailable offline.\n\nSo each of these CDN engines needs the network on **first** use, then works offline. To remove even the first-use dependency, build with `GEOLIBRE_PGLITE_CDN=0` and `GEOLIBRE_CEREUS_CDN=0`, which vendor PGlite/PostGIS and the CereusDB wasm back into the build under `/assets/`, where the same-origin rule covers them (PGlite alone re-adds ~22 MB to the Tauri binary).\n\nTo strip **all GeoLibre-controlled** external CDN references from the build output — for deployments that cannot load any resource from untrusted CDNs — use `GEOLIBRE_NO_EXTERNAL_CDN=1`. This implies `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0`, and additionally disables features whose code references external CDNs: storymap HTML export (which injects `<script>` tags from `unpkg.com`), built-in object detection models (YOLO ONNX weights from `cdn.jsdelivr.net`), Segment Everything, 3D Tiles Draco/KTX2 decoders, and GDAL export. Pyodide remains available when `VITE_PYODIDE_INDEX_URL` points to an approved mirror. Runtime `deployment.json` configures client policy; it does not change this build-time CDN packaging.\n\n**DuckDB-WASM goes the other way**, because it is on the critical path for opening a local vector file and so is bundled by default. `GEOLIBRE_DUCKDB_WASM_CDN=1` moves it to jsDelivr instead — or `npm run lite:build`, which sets that flag and then _asserts_ no emitted file exceeds the ceiling, so a regression fails the build rather than the upload. It is pinned to the installed version by duckdb-wasm's own `getJsDelivrBundles()`, so the fetched engine cannot drift from the loader compiled into the bundle. The flag exists for one reason: `duckdb-mvp.wasm` (~40 MB) and `duckdb-eh.wasm` (~35 MB) both exceed the **25 MiB per-asset limit on Cloudflare Pages and Workers static assets**, which rejects the upload outright, and nothing else in the build comes close (the next largest is ~22 MB). So this single flag decides whether the web build can be hosted there at all — it takes the output from ~251 MB to ~176 MB with no file over the ceiling. GitHub Pages allows 100 MB per file and needs none of this. The flag is ignored for both targets that ship no service worker: a Tauri build, which must stay offline-capable, and an embed build (`GEOLIBRE_EMBED=1`), where the engine would otherwise be refetched every notebook session with no runtime cache behind it. Neither has the size ceiling this exists for, since a binary and a wheel are not uploaded to Cloudflare. A CDN-hosted worker script cannot be passed to `new Worker` (it must be same-origin), so that variant wraps it in a same-origin blob that `importScripts` the real one; this needs `worker-src blob:` and the CDN in `script-src`, both already in `docker/nginx.conf`. The nested `importScripts` is matched against `script-src` rather than `worker-src` — verified against that policy in Chromium and Firefox, since Firefox has historically checked worker sub-resources against `worker-src`/`child-src`.\n\nTwo engines have no such escape hatch. **Pyodide** is always fetched: `VITE_PYODIDE_INDEX_URL` re-points it at a mirror you host — worth doing when jsDelivr is unreachable or disallowed — but note that neither CacheFirst rule matches such a mirror, since one is scoped to `cdn.jsdelivr.net` and the other to same-origin `/assets/`, so unless the mirror is served from under `/assets/` it falls back to ordinary HTTP caching rather than the service worker. **`gdal3.js`** — which backs the Georeferencer's client-side GeoTIFF/COG export — is never vendored at all: its wasm (~28 MB) and data (~12 MB) only ever come from the CDN, and `GEOLIBRE_GDAL_CDN=0` opts out of that rather than bundling them, leaving the loader with no paths and that export unavailable.\n\nThe **desktop** build ships no service worker, so none of this runtime caching applies there: it re-fetches every CDN-loaded engine on first use per install, unless PGlite/PostGIS and CereusDB are bundled with the flags above.\n\nA new deploy is picked up via `registerType: \"autoUpdate\"`: the new service worker installs in the background and takes control (`skipWaiting` + `clientsClaim`), so its fresh precache serves subsequent requests. Workbox's default force-reload-on-activation is deliberately suppressed via `onNeedReload` in `src/main.tsx` — on the relative-base `/demo/` subpath that reload fired spuriously and discarded in-progress map state. Page recovery is delegated to `installStaleChunkReload`, which reloads on-demand only when an orphaned lazy chunk 404s (cooldown-guarded; if `sessionStorage` is blocked it skips the reload and lets the preload error surface). Precached chunks are served from cache and never 404, so the page is no longer reloaded out from under the user.\n\n## Container image\n\nThe root Dockerfile packages the browser app: a Node build stage runs the\n`geolibre-desktop` workspace build, then copies `apps/geolibre-desktop/dist`\ninto nginx. The nginx config serves static assets and falls back to `index.html`\nfor browser-entry URLs.\n\nAt startup, the entrypoint validates the selected source policy, applies\nnonblank environment overrides, generates nginx guards, conditionally starts\nthe Python sidecar, and serves the generated public `/deployment.json`. The\nbrowser fetches that policy before first render, then resolves settings from\npolicy, runtime environment and build environment, in that order. See\n[Deployment Policy](deployment-policy.md) and\n[Self-Hosting: container policy enforcement](self-hosting.md#container-policy-enforcement).\n\nThe `Publish Container Image` workflow builds the image for pull requests and\npublishes it to GitHub Container Registry for pushes to `main`, version tags,\nand manual runs. The upstream image name is `ghcr.io/opengeos/geolibre`.\n\nThe image bundles the optional Python sidecar (uvicorn) and reverse-proxies it\nat `/sidecar`; it starts only when the final policy grants `processing:run` or\n`data:add` and `GEOLIBRE_DISABLE_SIDECAR` is not `1`. The container does not\nrun the Tauri desktop shell, so workflows that depend on desktop filesystem\naccess still require the installed desktop app. Client policy and nginx guards\ndo not enforce desktop processing, browser WASM, separately exposed sidecars,\nor plugin execution.\n\n## Security\n\n- The Tauri CSP pins `script-src` to version-specific jsDelivr paths and keeps\n  `connect-src` open to `https:`/`http:` for user-supplied data URLs; see\n  [Desktop CSP `script-src` allowlist](maintenance.md#desktop-csp-script-src-allowlist).\n- File access uses dialog-selected paths only.\n\n### Credential storage\n\nTokens and API keys saved in Settings (share token, Cesium Ion token, Mapbox\ntoken, ArcGIS API key, the secret fields of AI Assistant profiles), saved\nPostGIS connection strings, and the Share.GeoLibre OAuth refresh token live in\ndifferent places per build:\n\n- **Desktop (Tauri, macOS/Windows/Linux):** the OS credential store (macOS\n  Keychain, Windows Credential Manager, Linux Secret Service) under the service\n  `org.geolibre.desktop`, one entry per credential, through the\n  `secure_store_*` commands in `src-tauri/src/secure_store.rs`. The\n  `geolibre.desktopSettings` localStorage blob keeps those fields empty, and\n  `geolibre.postgres.connectionIds` holds only the non-secret order of the\n  `postgres.connection.<uuid>` entries. The index is written before a new\n  entry, so a crash never leaves an unreferenced credential; a startup that\n  finds an indexed id without a credential (its write failed) drops the id and\n  shows the warning. `lib/credential-hydration.ts` loads them into memory\n  before the app renders and migrates any plaintext values left in\n  localStorage by an older build, removing each only after its keychain write\n  succeeds. That load is the only read: `secure_store_get_many` answers once\n  per page load of a webview (the reload hook in `lib.rs` reopens it), and\n  `secure_store_seal`, which the external-plugin loader calls before importing\n  a plugin, closes it without reading, so plugin code cannot pull saved tokens\n  from the store (issue #2858). Writes and deletes stay open. The OAuth refresh token is stored per issuer as\n  `share.oauth.refreshToken.<issuer>` and rotates on every refresh; the access\n  token and the PKCE verifier stay in memory. Because the server revokes a\n  session when a consumed refresh token is presented, the issuer is recorded\n  in the non-secret `geolibre.share.oauth.unsavedIssuers` list before each\n  keychain write and removed once the latest queued write for it completes. A\n  launch that finds its issuer listed deletes the stored token and starts\n  signed out rather than trusting a possibly consumed one. If the list itself\n  cannot be written, the new token is not stored: a delete of the stored copy\n  is queued and the sign-in lasts only until GeoLibre closes. After a refresh\n  that delete is best effort; quitting before it finishes leaves the consumed\n  token stored, so the next launch signs out on its first refresh instead of\n  at startup. Sign-out waits for its delete before reporting signed out, and\n  sends the revoke request whether or not the delete succeeds. If the delete\n  fails, Settings reports the sign-out as incomplete; the delete stays queued\n  and is retried only with a later credential write while the app runs. After\n  a restart the token is discarded if its issuer is on the unsaved list; if\n  that list could not be written either, the token is restored, and it still\n  works only if the revoke request also failed.\n  Credentials a project file would otherwise carry (geocoding API keys\n  `preferences.geocoding.apiKeys` and secret Environment Variables) are\n  stored device-wide by name as `project.geocoding.apiKey.<providerId>` and\n  `project.env.<VARIABLE>`; the non-secret\n  `geolibre.projectCredentials.accounts` list indexes them, with the same\n  index-first write order. Saving a project moves geocoding keys and uniquely\n  named secret environment values to the keychain, leaving empty values in\n  the file. Duplicate or nameless secret environment rows cannot be assigned\n  a unique account and remain for the local keep/strip save prompt.\n  Settings writes a value only when the user changes it. Clearing a field\n  deletes the stored value only if the fi","isInternal":false,"tokens":11609,"sizeBytes":50096},{"name":"manifest.json","path":"extensions/geolibre-chrome/manifest.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/extensions/geolibre-chrome/manifest.json","title":"Open data in GeoLibre","category":"anthropic-skill","format":"json","content":"{\n  \"manifest_version\": 3,\n  \"name\": \"Open data in GeoLibre\",\n  \"description\": \"Find geospatial datasets and services used by the current page and open them in GeoLibre.\",\n  \"version\": \"0.4.0\",\n  \"homepage_url\": \"https://geolibre.app/\",\n  \"minimum_chrome_version\": \"106\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"Open data in GeoLibre\",\n    \"default_popup\": \"popup.html\",\n    \"default_icon\": {\n      \"16\": \"icons/geolibre-16.png\",\n      \"32\": \"icons/geolibre-32.png\"\n    }\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": false\n  },\n  \"icons\": {\n    \"16\": \"icons/geolibre-16.png\",\n    \"32\": \"icons/geolibre-32.png\",\n    \"48\": \"icons/geolibre-48.png\",\n    \"128\": \"icons/geolibre-128.png\"\n  },\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self'; object-src 'self'\"\n  }\n}\n","frontmatter":{"manifest_version":3,"name":"Open data in GeoLibre","description":"Find geospatial datasets and services used by the current page and open them in GeoLibre.","version":"0.4.0","homepage_url":"https://geolibre.app/","minimum_chrome_version":"106","permissions":["activeTab","scripting","storage"],"action":{"default_title":"Open data in GeoLibre","default_popup":"popup.html","default_icon":{"16":"icons/geolibre-16.png","32":"icons/geolibre-32.png"}},"options_ui":{"page":"options.html","open_in_tab":false},"icons":{"16":"icons/geolibre-16.png","32":"icons/geolibre-32.png","48":"icons/geolibre-48.png","128":"icons/geolibre-128.png"},"content_security_policy":{"extension_pages":"script-src 'self'; object-src 'self'"}},"isInternal":false,"tokens":267,"sizeBytes":863},{"name":"PRIVACY.md","path":"extensions/geolibre-chrome/PRIVACY.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/extensions/geolibre-chrome/PRIVACY.md","title":"Geolibre-chrome Skill","category":"anthropic-skill","format":"markdown","content":"# Privacy policy — Open data in GeoLibre\n\nLast updated: August 18, 2026\n\nOpen data in GeoLibre does not independently collect, retain, or sell personal\ninformation, page contents, or usage analytics. It does forward the complete\ndataset, service, and style URLs that the user explicitly selects,\nas described below. The only data it stores is an optional custom GeoLibre\nbase URL, described in the settings section below.\n\nThe extension uses Chrome's `activeTab` permission to inspect the current page\nonly after the user clicks the extension's toolbar icon. In that moment it reads\nthe page's links and structured metadata, and reads back the addresses of the\nrequests the page has already made, from the Resource Timing record each\ndocument keeps of its own loading, to identify geospatial services used by\ninteractive maps. It does not inspect response bodies, and it does not observe\nthe network or run in the background at any other time. Nothing is stored: the\nlist exists only while the popup is open and is discarded when it closes.\n\nWhen the user chooses **Open in GeoLibre**, the complete selected HTTP(S)\ndataset and style URLs are placed in the query string of a new tab opened on\nthe configured GeoLibre destination: `https://web.geolibre.app/` by default, or\nthe instance the user set on the settings page (see below), in which case that\ninstance receives the URLs instead. These URLs are forwarded verbatim and may\ncontain signed query parameters, access tokens, user identifiers, or other\npersonal data. Do not select a URL containing information you do not want to\nsend to that GeoLibre instance.\n\nThe navigation request exposes its URL and the user's IP address to GeoLibre's\nweb-hosting infrastructure, where standard service logs may retain them. The\nnavigation may also appear in browser history. See the current\n[GeoLibre privacy policy](https://geolibre.app/privacy/) for the service's data\npractices. The extension itself never persists a URL.\n\nThe extension does not fetch or upload the datasets. GeoLibre requests them\ndirectly from their original servers, subject to those servers' privacy\npolicies and CORS configuration. Cookies and other browser-session credentials\nfrom the source page are not forwarded, although credentials embedded directly\nin a selected URL are part of the URL and are forwarded.\n\nThe extension's settings page (open it from `chrome://extensions`) lets the\nuser replace the default `https://web.geolibre.app/` destination with a\ndifferent GeoLibre instance, for example a self-hosted one. That single URL is\nkept in Chrome's synced extension storage, which Chrome may sync across the\nuser's signed-in browsers under their own Google account; it is never sent\nanywhere by the extension itself except as the destination of the \"Open in\nGeoLibre\" tab it opens. Clearing it in the settings page or uninstalling the\nextension removes it.\n\nThe extension uses no remote code, advertising, analytics, tracking pixels,\ncookies, or accounts. It holds no host permissions and no permission to\nobserve browsing.\n\nQuestions may be submitted through the GeoLibre repository:\n<https://github.com/opengeos/GeoLibre/issues>.\n","isInternal":false,"tokens":678,"sizeBytes":3162},{"name":"STORE_LISTING.md","path":"extensions/geolibre-chrome/STORE_LISTING.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/extensions/geolibre-chrome/STORE_LISTING.md","title":"Geolibre-chrome Skill","category":"anthropic-skill","format":"markdown","content":"# Chrome Web Store listing\n\nPublished listing:\n<https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj>\n\n## Name\n\nOpen data in GeoLibre\n\n## Summary\n\nFind geospatial datasets and map services on a webpage and open them in GeoLibre.\n\n## Detailed description\n\nOpen data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map.\n\nClick the extension icon to find supported data links on the current page, filter them by vector, raster, or LiDAR type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds (LAS, LAZ, COPC, and EPT), and ZIP archives containing GeoJSON.\n\nThe extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages.\n\nInteractive maps are supported too. The extension recognizes the WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests the current page has already made.\n\nThe extension reads the page only when you click its icon, holds no standing access to any website, runs nothing in the background, and stores only one setting: the GeoLibre address \"Open in GeoLibre\" uses, which you can point at your own instance. It runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it.\n\nDataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred.\n\n## Category\n\nDeveloper Tools\n\n## Language\n\nEnglish\n\n## Permission justification\n\nEach block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. As of 0.4.0 the manifest requests `activeTab`, `scripting`, and `storage`, so the webRequest and host-permission fields still do not appear.\n\n### activeTab\n\nactiveTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time.\n\n### scripting\n\nscripting injects two packaged functions into the active tab when the user opens the popup. One reads the page's links and metadata to find dataset files. The other reads back the addresses of the requests the page has already made, so the map services it draws can be recognized; a map fetches those from JavaScript, so they are never links in the document. Both functions are contained in the extension package, so no remote code is involved. They run once per invocation and return their results to the popup.\n\n### storage\n\nstorage holds a single setting: the base URL \"Open in GeoLibre\" opens, in case the user runs their own GeoLibre instance instead of the hosted default. It is read only when the popup opens and only written from the extension's own settings page. No other data is stored.\n\n### Not requested\n\nThe extension requests no host permissions, and no permission to watch network requests or run in the background. It does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. Answer \"No, I am not using remote code\": every script it runs ships inside the package.\n","isInternal":false,"tokens":864,"sizeBytes":4026},{"name":"package.json","path":"packages/collab-core/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/package.json","title":"Collab-core Skill","category":"anthropic-skill","format":"json","content":"# Collab-core Skill\n\nPath: `packages/collab-core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/package.json)","tokens":300,"sizeBytes":184},{"name":"tsconfig.json","path":"packages/collab-core/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/tsconfig.json","title":"Collab-core Skill","category":"anthropic-skill","format":"json","content":"# Collab-core Skill\n\nPath: `packages/collab-core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/tsconfig.json)","tokens":300,"sizeBytes":186},{"name":"package.json","path":"packages/core/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/package.json","title":"Core Skill","category":"anthropic-skill","format":"json","content":"# Core Skill\n\nPath: `packages/core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/package.json)","tokens":300,"sizeBytes":163},{"name":"tsconfig.json","path":"packages/core/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/tsconfig.json","title":"Core Skill","category":"anthropic-skill","format":"json","content":"# Core Skill\n\nPath: `packages/core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/tsconfig.json)","tokens":300,"sizeBytes":165},{"name":"package.json","path":"packages/embed/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/package.json","title":"Embed Skill","category":"anthropic-skill","format":"json","content":"# Embed Skill\n\nPath: `packages/embed/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/package.json)","tokens":300,"sizeBytes":166},{"name":"tsconfig.build.json","path":"packages/embed/tsconfig.build.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.build.json","title":"Embed Skill","category":"anthropic-skill","format":"json","content":"# Embed Skill\n\nPath: `packages/embed/tsconfig.build.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.build.json)","tokens":300,"sizeBytes":180},{"name":"tsconfig.json","path":"packages/embed/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.json","title":"Embed Skill","category":"anthropic-skill","format":"json","content":"# Embed Skill\n\nPath: `packages/embed/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.json)","tokens":300,"sizeBytes":168},{"name":"package.json","path":"packages/map/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/package.json","title":"Map Skill","category":"anthropic-skill","format":"json","content":"# Map Skill\n\nPath: `packages/map/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/package.json)","tokens":300,"sizeBytes":160},{"name":"tsconfig.json","path":"packages/map/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/tsconfig.json","title":"Map Skill","category":"anthropic-skill","format":"json","content":"# Map Skill\n\nPath: `packages/map/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/tsconfig.json)","tokens":300,"sizeBytes":162},{"name":"package.json","path":"packages/plugins/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/plugins/package.json","title":"@geolibre/plugins","category":"anthropic-skill","format":"json","content":"{\n  \"name\": \"@geolibre/plugins\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./assistant-tool-registry\": \"./src/assistant-tool-registry.ts\",\n    \"./atl03\": \"./src/plugins/atl03.ts\",\n    \"./cog-spectral-profile\": \"./src/plugins/cog-spectral-profile.ts\",\n    \"./earthdata-relay\": \"./src/plugins/earthdata-relay.ts\",\n    \"./elevation-profile-chart\": \"./src/plugins/elevation-profile/chart/profileChart.ts\",\n    \"./geo-editor-geometry\": \"./src/plugins/geo-editor-geometry.ts\",\n    \"./lidar\": \"./src/plugins/components/lidar.ts\",\n    \"./local-netcdf\": \"./src/plugins/local-netcdf.ts\",\n    \"./maplibre-graticule\": \"./src/plugins/maplibre-graticule.ts\",\n    \"./maplibre-vantor/style.css\": \"./src/plugins/vantor/styles.css\",\n    \"./mgrs-reference\": \"./src/plugins/mgrs-reference.ts\",\n    \"./plugin-ids\": \"./src/plugin-ids.ts\",\n    \"./raster-symbology\": \"./src/plugins/raster-symbology.ts\",\n    \"./right-panel-registry\": \"./src/right-panel-registry.ts\",\n    \"./zarr-time-axis\": \"./src/plugins/zarr-time-axis.ts\"\n  },\n  \"dependencies\": {\n    \"@carbonplan/zarr-layer\": \"0.11.0\",\n    \"@deck.gl/aggregation-layers\": \"^9.4.0\",\n    \"@deck.gl/core\": \"^9.4.0\",\n    \"@deck.gl/geo-layers\": \"^9.4.0\",\n    \"@deck.gl/layers\": \"^9.4.0\",\n    \"@deck.gl/mapbox\": \"^9.4.0\",\n    \"@deck.gl/maplibre\": \"^9.4.0\",\n    \"@deck.gl/mesh-layers\": \"^9.4.0\",\n    \"@developmentseed/deck.gl-geotiff\": \"^0.8.1\",\n    \"@developmentseed/deck.gl-raster\": \"^0.8.1\",\n    \"@developmentseed/morecantile\": \"^0.8.1\",\n    \"@dvt3d/maplibre-three-plugin\": \"^1.7.1\",\n    \"@esri/maplibre-arcgis\": \"^1.3.1\",\n    \"@geolibre/core\": \"*\",\n    \"@geolibre/map\": \"*\",\n    \"@geoman-io/maplibre-geoman-free\": \"^0.10.1\",\n    \"@loaders.gl/i3s\": \"^4.5.4\",\n    \"@luma.gl/core\": \"^9.4.2\",\n    \"@luma.gl/engine\": \"^9.4.2\",\n    \"@luma.gl/webgl\": \"^9.4.2\",\n    \"@mapbox/vector-tile\": \"^3.0.0\",\n    \"@maplibre/maplibre-gl-directions\": \"^0.10.1\",\n    \"@strands-agents/sdk\": \"^1.21.0\",\n    \"@tauri-apps/api\": \"^2.12.4\",\n    \"@tauri-apps/plugin-opener\": \"^2.7.2\",\n    \"@turf/union\": \"^7.4.0\",\n    \"a5-js\": \"^1.0.0\",\n    \"cog-tiler-wasm\": \"^0.5.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"h3-js\": \"^4.5.0\",\n    \"h5wasm\": \"^0.10.3\",\n    \"hyparquet\": \"^1.31.4\",\n    \"hyparquet-compressors\": \"^1.1.2\",\n    \"icechunk-js\": \"^0.6.1\",\n    \"jspdf\": \"^4.2.1\",\n    \"lerc\": \"^4.2.0\",\n    \"mapillary-js\": \"^4.1.2\",\n    \"maplibre-gl\": \"^6.15.0\",\n    \"maplibre-gl-3d-tiles\": \"^0.5.11\",\n    \"maplibre-gl-basemap-control\": \"^0.14.4\",\n    \"maplibre-gl-components\": \"^0.31.2\",\n    \"maplibre-gl-duckdb\": \"^0.2.4\",\n    \"maplibre-gl-earth-engine\": \"^0.4.2\",\n    \"maplibre-gl-enviroatlas\": \"^0.1.1\",\n    \"maplibre-gl-esri-wayback\": \"^0.2.2\",\n    \"maplibre-gl-fema-wms\": \"^0.1.2\",\n    \"maplibre-gl-geo-editor\": \"^0.11.2\",\n    \"maplibre-gl-geoagent\": \"^0.6.1\",\n    \"maplibre-gl-lidar\": \"^0.21.0\",\n    \"maplibre-gl-nasa-earthdata\": \"^0.1.4\",\n    \"maplibre-gl-national-map\": \"^0.1.1\",\n    \"maplibre-gl-overture-maps\": \"^0.4.1\",\n    \"maplibre-gl-planetary-computer\": \"^0.4.0\",\n    \"maplibre-gl-raster\": \"^0.14.15\",\n    \"maplibre-gl-splat\": \"^0.2.9\",\n    \"maplibre-gl-streetview\": \"^0.8.0\",\n    \"maplibre-gl-swipe\": \"^0.14.0\",\n    \"maplibre-gl-time-slider\": \"^1.8.7\",\n    \"maplibre-gl-usgs-lidar\": \"^0.11.5\",\n    \"maplibre-gl-vector\": \"^0.11.1\",\n    \"mgrs\": \"^2.2.0\",\n    \"netcdfjs\": \"^4.0.0\",\n    \"ngeohash\": \"^0.6.4\",\n    \"open-location-code-typescript\": \"^1.5.0\",\n    \"pbf\": \"^5.1.2\",\n    \"pmtiles\": \"^4.5.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\",\n    \"satellite.js\": \"^7.1.0\",\n    \"shpjs\": \"^6.2.0\",\n    \"three\": \"^0.186.1\",\n    \"webdggrid\": \"^1.9.3\",\n    \"whitebox-wasm\": \"^0.6.1\",\n    \"zarrita\": \"^0.7.5\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"@types/ngeohash\": \"^0.6.8\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","frontmatter":{"name":"@geolibre/plugins","version":"3.4.0","private":true,"type":"module","main":"./src/index.ts","types":"./src/index.ts","exports":{".":"./src/index.ts","./assistant-tool-registry":"./src/assistant-tool-registry.ts","./atl03":"./src/plugins/atl03.ts","./cog-spectral-profile":"./src/plugins/cog-spectral-profile.ts","./earthdata-relay":"./src/plugins/earthdata-relay.ts","./elevation-profile-chart":"./src/plugins/elevation-profile/chart/profileChart.ts","./geo-editor-geometry":"./src/plugins/geo-editor-geometry.ts","./lidar":"./src/plugins/components/lidar.ts","./local-netcdf":"./src/plugins/local-netcdf.ts","./maplibre-graticule":"./src/plugins/maplibre-graticule.ts","./maplibre-vantor/style.css":"./src/plugins/vantor/styles.css","./mgrs-reference":"./src/plugins/mgrs-reference.ts","./plugin-ids":"./src/plugin-ids.ts","./raster-symbology":"./src/plugins/raster-symbology.ts","./right-panel-registry":"./src/right-panel-registry.ts","./zarr-time-axis":"./src/plugins/zarr-time-axis.ts"},"dependencies":{"@carbonplan/zarr-layer":"0.11.0","@deck.gl/aggregation-layers":"^9.4.0","@deck.gl/core":"^9.4.0","@deck.gl/geo-layers":"^9.4.0","@deck.gl/layers":"^9.4.0","@deck.gl/mapbox":"^9.4.0","@deck.gl/maplibre":"^9.4.0","@deck.gl/mesh-layers":"^9.4.0","@developmentseed/deck.gl-geotiff":"^0.8.1","@developmentseed/deck.gl-raster":"^0.8.1","@developmentseed/morecantile":"^0.8.1","@dvt3d/maplibre-three-plugin":"^1.7.1","@esri/maplibre-arcgis":"^1.3.1","@geolibre/core":"*","@geolibre/map":"*","@geoman-io/maplibre-geoman-free":"^0.10.1","@loaders.gl/i3s":"^4.5.4","@luma.gl/core":"^9.4.2","@luma.gl/engine":"^9.4.2","@luma.gl/webgl":"^9.4.2","@mapbox/vector-tile":"^3.0.0","@maplibre/maplibre-gl-directions":"^0.10.1","@strands-agents/sdk":"^1.21.0","@tauri-apps/api":"^2.12.4","@tauri-apps/plugin-opener":"^2.7.2","@turf/union":"^7.4.0","a5-js":"^1.0.0","cog-tiler-wasm":"^0.5.0","dggal":"^0.0.6","fflate":"^0.8.3","geotiff":"^3.0.5","geotiff-geokeys-to-proj4":"^2026.10.9","h3-js":"^4.5.0","h5wasm":"^0.10.3","hyparquet":"^1.31.4","hyparquet-compressors":"^1.1.2","icechunk-js":"^0.6.1","jspdf":"^4.2.1","lerc":"^4.2.0","mapillary-js":"^4.1.2","maplibre-gl":"^6.15.0","maplibre-gl-3d-tiles":"^0.5.11","maplibre-gl-basemap-control":"^0.14.4","maplibre-gl-components":"^0.31.2","maplibre-gl-duckdb":"^0.2.4","maplibre-gl-earth-engine":"^0.4.2","maplibre-gl-enviroatlas":"^0.1.1","maplibre-gl-esri-wayback":"^0.2.2","maplibre-gl-fema-wms":"^0.1.2","maplibre-gl-geo-editor":"^0.11.2","maplibre-gl-geoagent":"^0.6.1","maplibre-gl-lidar":"^0.21.0","maplibre-gl-nasa-earthdata":"^0.1.4","maplibre-gl-national-map":"^0.1.1","maplibre-gl-overture-maps":"^0.4.1","maplibre-gl-planetary-computer":"^0.4.0","maplibre-gl-raster":"^0.14.15","maplibre-gl-splat":"^0.2.9","maplibre-gl-streetview":"^0.8.0","maplibre-gl-swipe":"^0.14.0","maplibre-gl-time-slider":"^1.8.7","maplibre-gl-usgs-lidar":"^0.11.5","maplibre-gl-vector":"^0.11.1","mgrs":"^2.2.0","netcdfjs":"^4.0.0","ngeohash":"^0.6.4","open-location-code-typescript":"^1.5.0","pbf":"^5.1.2","pmtiles":"^4.5.0","proj4":"^2.22.0","s2js":"^1.44.0","satellite.js":"^7.1.0","shpjs":"^6.2.0","three":"^0.186.1","webdggrid":"^1.9.3","whitebox-wasm":"^0.6.1","zarrita":"^0.7.5"},"devDependencies":{"@types/geojson":"^7946.0.16","@types/ngeohash":"^0.6.8","typescript":"^7.0.2"}},"isInternal":false,"tokens":1527,"sizeBytes":3946},{"name":"tsconfig.json","path":"packages/plugins/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/plugins/tsconfig.json","title":"Plugins Skill","category":"anthropic-skill","format":"json","content":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"paths\": {}\n  },\n  \"include\": [\"src\"]\n}\n","frontmatter":{"extends":"../../tsconfig.base.json","compilerOptions":{"paths":{}},"include":["src"]},"isInternal":false,"tokens":32,"sizeBytes":110},{"name":"package.json","path":"packages/processing/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/processing/package.json","title":"@geolibre/processing","category":"anthropic-skill","format":"json","content":"{\n  \"name\": \"@geolibre/processing\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"dependencies\": {\n    \"@bjorn3/browser_wasi_shim\": \"^0.4.2\",\n    \"@geolibre/core\": \"*\",\n    \"@turf/area\": \"^7.4.0\",\n    \"@turf/bbox\": \"^7.4.0\",\n    \"@turf/bearing\": \"^7.4.0\",\n    \"@turf/boolean-contains\": \"^7.4.0\",\n    \"@turf/boolean-intersects\": \"^7.4.0\",\n    \"@turf/boolean-within\": \"^7.4.0\",\n    \"@turf/buffer\": \"^7.4.0\",\n    \"@turf/centroid\": \"^7.4.0\",\n    \"@turf/circle\": \"^7.4.0\",\n    \"@turf/convex\": \"^7.4.0\",\n    \"@turf/destination\": \"^7.3.5\",\n    \"@turf/difference\": \"^7.4.0\",\n    \"@turf/dissolve\": \"^7.4.0\",\n    \"@turf/distance\": \"^7.3.5\",\n    \"@turf/envelope\": \"^7.4.0\",\n    \"@turf/flatten\": \"^7.3.5\",\n    \"@turf/helpers\": \"^7.4.0\",\n    \"@turf/intersect\": \"^7.4.0\",\n    \"@turf/sector\": \"^7.4.0\",\n    \"@turf/simplify\": \"^7.4.0\",\n    \"@turf/tin\": \"^7.4.0\",\n    \"@turf/union\": \"^7.4.0\",\n    \"@turf/voronoi\": \"^7.4.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geolibre-wasm\": \"^1.7.1\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"onnxruntime-web\": \"1.30.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n","frontmatter":{"name":"@geolibre/processing","version":"3.4.0","private":true,"type":"module","main":"./src/index.ts","types":"./src/index.ts","exports":{".":"./src/index.ts"},"dependencies":{"@bjorn3/browser_wasi_shim":"^0.4.2","@geolibre/core":"*","@turf/area":"^7.4.0","@turf/bbox":"^7.4.0","@turf/bearing":"^7.4.0","@turf/boolean-contains":"^7.4.0","@turf/boolean-intersects":"^7.4.0","@turf/boolean-within":"^7.4.0","@turf/buffer":"^7.4.0","@turf/centroid":"^7.4.0","@turf/circle":"^7.4.0","@turf/convex":"^7.4.0","@turf/destination":"^7.3.5","@turf/difference":"^7.4.0","@turf/dissolve":"^7.4.0","@turf/distance":"^7.3.5","@turf/envelope":"^7.4.0","@turf/flatten":"^7.3.5","@turf/helpers":"^7.4.0","@turf/intersect":"^7.4.0","@turf/sector":"^7.4.0","@turf/simplify":"^7.4.0","@turf/tin":"^7.4.0","@turf/union":"^7.4.0","@turf/voronoi":"^7.4.0","dggal":"^0.0.6","fflate":"^0.8.3","geolibre-wasm":"^1.7.1","geotiff":"^3.0.5","geotiff-geokeys-to-proj4":"^2026.10.9","onnxruntime-web":"1.30.0","proj4":"^2.22.0","s2js":"^1.44.0"},"devDependencies":{"@types/geojson":"^7946.0.16","typescript":"^7.0.2"}},"isInternal":false,"tokens":581,"sizeBytes":1353},{"name":"tsconfig.json","path":"packages/processing/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/processing/tsconfig.json","title":"Processing Skill","category":"anthropic-skill","format":"json","content":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","frontmatter":{"extends":"../../tsconfig.base.json","include":["src"]},"isInternal":false,"tokens":19,"sizeBytes":66},{"name":"package.json","path":"packages/ui/package.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/ui/package.json","title":"@geolibre/ui","category":"anthropic-skill","format":"json","content":"{\n  \"name\": \"@geolibre/ui\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./globals.css\": \"./src/globals.css\",\n    \"./theme.css\": \"./src/theme.css\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.3.1\",\n    \"@radix-ui/react-direction\": \"^1.2.0\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.2.1\",\n    \"@radix-ui/react-label\": \"^2.1.17\",\n    \"@radix-ui/react-scroll-area\": \"^1.4.0\",\n    \"@radix-ui/react-separator\": \"^1.2.0\",\n    \"@radix-ui/react-slider\": \"^1.6.1\",\n    \"@radix-ui/react-slot\": \"^1.5.0\",\n    \"@radix-ui/react-tabs\": \"^1.2.1\",\n    \"@radix-ui/react-tooltip\": \"^1.4.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-react\": \"^1.58.0\",\n    \"react\": \"^19.3.1\",\n    \"react-dom\": \"^19.3.1\",\n    \"tailwind-merge\": \"^3.7.0\",\n    \"tw-animate-css\": \"^1.4.0\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.3.0\",\n    \"@types/react-dom\": \"^19.3.0\",\n    \"typescript\": \"^7.0.2\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  }\n}\n","frontmatter":{"name":"@geolibre/ui","version":"3.4.0","private":true,"type":"module","main":"./src/index.ts","types":"./src/index.ts","exports":{".":"./src/index.ts","./globals.css":"./src/globals.css","./theme.css":"./src/theme.css"},"dependencies":{"@radix-ui/react-dialog":"^1.3.1","@radix-ui/react-direction":"^1.2.0","@radix-ui/react-dropdown-menu":"^2.2.1","@radix-ui/react-label":"^2.1.17","@radix-ui/react-scroll-area":"^1.4.0","@radix-ui/react-separator":"^1.2.0","@radix-ui/react-slider":"^1.6.1","@radix-ui/react-slot":"^1.5.0","@radix-ui/react-tabs":"^1.2.1","@radix-ui/react-tooltip":"^1.4.1","class-variance-authority":"^0.7.1","clsx":"^2.1.1","lucide-react":"^1.58.0","react":"^19.3.1","react-dom":"^19.3.1","tailwind-merge":"^3.7.0","tw-animate-css":"^1.4.0"},"devDependencies":{"@types/react":"^19.3.0","@types/react-dom":"^19.3.0","typescript":"^7.0.2"},"peerDependencies":{"react":"^19.0.0","react-dom":"^19.0.0"}},"isInternal":false,"tokens":416,"sizeBytes":1124},{"name":"tsconfig.json","path":"packages/ui/tsconfig.json","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/ui/tsconfig.json","title":"Ui Skill","category":"anthropic-skill","format":"json","content":"{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n","frontmatter":{"extends":"../../tsconfig.base.json","include":["src"]},"isInternal":false,"tokens":19,"sizeBytes":66},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nGuidance for Claude Code (claude.ai/code) when working in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`,\n`workers/*`) plus two non-npm components: a Python FastAPI sidecar\n(`backend/geolibre_server`) and a separate Python package (`python/`, the\n`geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS\nworkspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2**\n(`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx\n(Docker), and embedded in Jupyter (the `python/` package bundles a build of the\nweb app into its wheel).\n\n## Commands\n\n```bash\nnpm run dev            # web dev server → http://localhost:5173\nnpm run tauri:dev      # desktop app (required for filesystem dialogs, local MBTiles, local raster reads)\nnpm run build          # production web build → apps/geolibre-desktop/dist/\nnpm run lite:build     # same, but DuckDB-WASM from jsDelivr — for hosts with a per-asset size cap\nnpm run tauri:build    # desktop installers → apps/geolibre-desktop/src-tauri/target/release/bundle/\nnpm run typecheck      # alias for the full build (tsc -b && vite build) — writes to dist/, not a pure type-check\nnpm run ci             # full local gate: lint + ci:frontend + typecheck:tests + ci:backend + check:rust\nnpm run ci:frontend    # build + frontend + worker tests (CI runs the halves as parallel jobs)\nnpm run ci:backend     # backend + docker/tests (needs Python and the npm CLI, no npm install)\n```\n\nTests:\n\n```bash\nnpm run test:frontend                              # node --test over tests/*.test.ts (tsx loader)\nnpm run test:frontend:coverage                     # same, plus a coverage summary (gated on a floor)\nnode --import tsx --test tests/<name>.test.ts      # a single frontend test file\nnpm run test:backend                               # pytest backend/geolibre_server/tests\nnpm run test:backend:coverage                      # same, plus pytest-cov (gated on a floor)\npython -m pytest backend/geolibre_server/tests/test_x.py::test_y   # a single backend test\nnpm run test:worker                                # typecheck workers/viewer\nnpm run test:e2e                                   # Playwright smoke tests (e2e/) against the built web app\nnpm run check:rust                                 # cargo check the Tauri crate\nnpm run typecheck:tests                            # tsc over tests/, ratcheted on an error-count baseline (docs/maintenance.md)\ncd python && pytest                                # the geolibre Python package's own suite\n```\n\nSetup notes that bite:\n\n- Install the backend **`test`** extra or the vector/raster/SQL/ML tests skip\n  themselves and CI is green but hollow:\n  `pip install -e \"backend/geolibre_server[test]\"`.\n- `npm run test:e2e` needs `npx playwright install chromium` once. It builds the\n  app, serves it with `vite preview`, and drives it with Playwright; add specs\n  under `e2e/`.\n- Coverage floors act as a **ratchet** and the frontend report only counts files\n  a test imports, so the first test for a big module can read as a regression.\n  See [Coverage floors](docs/maintenance.md#coverage-floors) before touching a\n  floor.\n\nThe `python/` package is built into a wheel via `npm run build:embed` (produces\n`apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its\nversion is dynamic, sourced from `python/src/geolibre/__init__.py`.\n\n## Pre-commit\n\n`.pre-commit-config.yaml` includes a **local `npm-build` hook**, so\n`pre-commit run` compiles the whole app — it is slow and can touch unrelated\nbuild state. Scope it to the files you changed:\n`pre-commit run --files <paths>`. Run it before pushing.\n\n## Architecture (the parts that span files)\n\nThe app is **store-driven**. `@geolibre/core` holds the Zustand store, domain\ntypes, and the `.geolibre.json` project schema — it is the single source of\ntruth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file\n   picker, drag-and-drop, or a plugin control.\n2. Local vector files MapLibre can't render directly are converted to GeoJSON\n   in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` →\n   `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs`\n   with a DuckDB fallback; KMZ unzipped client-side), then `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer`\n   records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers`\n   (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control.\n   **You don't mutate MapLibre directly from UI** — change store state and let\n   sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for\nraster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map`\n(MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) ·\n`@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins`\n(plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed\nclient) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** built-in plugins live in `packages/plugins/src/plugins/`, are\nexported from that package's `index.ts`, and registered in\n`apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips\nor a `plugin.json` manifest; bundled drop-ins under\n`apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop\nbuilds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`):\nbacks the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio).\nThe desktop app starts it on demand. It is **optional** — Vector tools run\nclient-side with Turf.js and only use the sidecar's `/vector` endpoints\n(GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog\nfalls back to the client engine via `/vector/status`. Optional extras:\n`conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are\namd64-only. The browser build proxies the sidecar at `/sidecar` (same-origin, no\nCORS), confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles\nuse a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script):\na headless stdio MCP server that authors `.geolibre.json` files. It is layered so\nnothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob),\n`authoring.py` *applies* them to a whole project (add/remove/restyle a layer,\nmove the camera, compose the legend/colorbar/swipe controls), and both `Map` and\nthe MCP tools delegate to `authoring.py`. `server.py` is the only module that\nimports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py`\nconfines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines\nto `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself\nwithout the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it\nand the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- CSP: `connect-src`/`img-src` allow any `https:` (desktop also `http:`), so a\n  new map/tile/service host needs no CSP change. `script-src` is pinned: the\n  desktop (`tauri.conf.json`) and web (`docker/nginx.conf`) CSPs list\n  version-specific jsDelivr paths that `tests/tauri-csp.test.ts` re-derives, so\n  a bump that moves a CDN script URL (or a new script host) must update both\n  files — see [`docs/maintenance.md`](docs/maintenance.md#desktop-csp-script-src-allowlist).\n  Map/tile-host CORS for selected release assets is handled by a dev-server\n  raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in\n  `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- UI strings are translatable via **react-i18next**; catalogs live in\n  `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of\n  truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings. The UI\n  mirrors for right-to-left locales, so style new components with Tailwind's\n  logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…),\n  not the physical `ml-`/`left-` forms. **Read `docs/i18n.md` in full before any\n  i18n work** — skipping it is the most common source of locale bugs.\n- **Some things upstream doesn't export are mirrored by hand, and drift fails\n  silently.** Before bumping `maplibre-gl`, any `maplibre-gl-*` package,\n  `geolibre-wasm`, or `@tauri-apps/plugin-http` — including Dependabot PRs — read\n  [`docs/maintenance.md`](docs/maintenance.md) and run the frontend suite. That\n  page also covers the coverage floors, the `npm audit` allowlist, the npm\n  publish layout for `@geolibre/core`/`@geolibre/map`, the committed\n  `backend/geolibre_server/uv.lock`, and the generated files (Whitebox menu\n  catalog, `npm run i18n:tools`, the `skills/geolibre/` agent skill) that must be\n  regenerated or updated in the same PR.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`,\n  `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/agent-skill.md`,\n  `docs/i18n.md`, `docs/maintenance.md`, `docs/contributing.md`.\n","isInternal":false,"tokens":2490,"sizeBytes":9433},{"name":"README.md","path":"apps/geolibre-desktop/public/plugins/README.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/apps/geolibre-desktop/public/plugins/README.md","title":"plugins Documentation","category":"plugin-manifest","format":"markdown","content":"# plugins Documentation\n\nPath: `apps/geolibre-desktop/public/plugins/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/apps/geolibre-desktop/public/plugins/README.md)","tokens":300,"sizeBytes":214},{"name":"README.md","path":"extensions/geolibre-chrome/README.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/extensions/geolibre-chrome/README.md","title":"geolibre-chrome Documentation","category":"plugin-manifest","format":"markdown","content":"# Open data in GeoLibre\n\nA Manifest V3 Chrome extension that finds supported geospatial dataset links on\nthe current page, reads back the geospatial service requests its interactive\nmaps have already made, and opens selected data in GeoLibre.\n\n## Install\n\nThe published extension is on the Chrome Web Store:\n\n[Open data in GeoLibre](https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj)\n\n## Install locally (development)\n\n1. Open `chrome://extensions`.\n2. Enable **Developer mode**.\n3. Choose **Load unpacked**.\n4. Select this `extensions/geolibre-chrome` directory.\n\nEverything happens after you click the toolbar icon: the extension scans the\ndocument's links, and reads each reachable frame's Resource Timing buffer to\nrecognize the\nservices its maps requested. It holds no permission beyond `activeTab`,\n`scripting`, and `storage`, and runs no background service worker. The only\nthing `storage` holds is an optional custom GeoLibre base URL, set from the\nextension's settings page; nothing else is stored.\n\n## Settings\n\nRight-click the toolbar icon and choose **Options** (or open\n`chrome://extensions`, find the extension, and choose **Extension options**) to\nset a different GeoLibre base URL, for example a self-hosted instance. Leaving\nit unset keeps the default, `https://web.geolibre.app/`.\n\n## Package for the Chrome Web Store\n\nFrom the repository root, run:\n\n```bash\nnpm run build:chrome-extension\n```\n\nThe versioned upload ZIP is written to `dist/`. Store listing copy and\npermission explanations are in `STORE_LISTING.md`; the publishable privacy\npolicy source is in `PRIVACY.md`. The policy must be hosted at a public URL\nbefore submitting the listing.\n\n## Discovery\n\nThe scanner recognizes GeoJSON/JSON, GeoParquet/Parquet, PMTiles, GeoTIFF/COG,\nLiDAR point clouds (LAS/LAZ/COPC and EPT `ept.json`), and ZIP links in anchors, linked resources, selected data attributes, and\nschema.org JSON-LD `contentUrl`/`downloadUrl` fields. Existing GeoLibre deep\nlinks are unpacked, and a neighboring `name.style.json` or\n`name.geolibre.style.json` link is paired with the matching dataset. A point\ncloud whose URL has no LAS/LAZ suffix, found only because its link or metadata\nsays COPC, LAZ, or LASzip, is opened with `dataType=lidar` so GeoLibre does not\nread it as GeoJSON.\n\nSource Cooperative repository pages receive additional handling: the extension\nreads the complete embedded file inventory even when the visible table is\nvirtualized, canonicalizes links to `data.source.coop`, and removes duplicate\npage/download links. The popup can filter discovered files by vector, raster,\nor LiDAR type without changing the current selection.\n\n## Services\n\nA map fetches its tiles and service documents from JavaScript, so they are never\nlinks in the page. What the extension reads instead is `performance\n.getEntriesByType(\"resource\")`, the record of its own requests that every\ndocument keeps, collected from the top frame and every same-origin frame below\nit. Recognized\nare WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, XYZ/TMS image\ntiles, and PBF/MVT vector tiles. Tile requests are collapsed into reusable\n`{z}/{x}/{y}` templates, and repeated requests from the same service appear once.\n\nThree consequences of reading the buffer rather than watching the network:\n\n- **Worker requests are invisible.** MapLibre and similar renderers fetch vector\n  tiles from a web worker, which records them in the worker's own timeline, not\n  the document's. Such a tileset is recovered from the metadata the main thread\n  *did* fetch: its TileJSON, or failing that the style document, which GeoLibre\n  can resolve a layer from on its own. A style is only offered in its own right\n  when no tileset from its origin was found.\n\n  A TileJSON is recognized by name (`tile.json`, `tiles.json`, `tilejson.json`),\n  which is narrower than every way a server can name one: tileserver-gl serves\n  `/data/<id>.json`, and that tileset is reached through its style rather than\n  its metadata. Nothing reads the response, so a TileJSON describing *raster*\n  tiles is indistinguishable from a vector one and is offered as a vector\n  tileset. Such a false positive cannot become a layer: Add Data resolves the\n  document on submit and refuses it when no source layers come out. Reading the\n  body here would mean fetching a cross-origin URL, which needs the host\n  permissions this design exists to avoid.\n\n  A style stands as a candidate of its own only when its path names it one:\n  `…/style.json`, `…/styles.json`, or an ArcGIS `…/resources/styles/<name>.json`.\n  The looser `…/styles/<name>.json` is an ordinary theme or configuration route\n  as well, so a style matched that way is still trusted to explain a tileset\n  found at its origin, but never offered on its own, where a page's theme file\n  would appear as a layer.\n- **The buffer is finite.** It holds 250 entries per document by default and\n  stops recording once full. A map's own early requests are normally well inside\n  that, but a very busy page can lose a service added late. Raising the limit\n  needs a `document_start` script, which needs the broad host permissions this\n  design exists to avoid, so the cap is accepted.\n- **Cross-origin frames are out of reach.** `activeTab` grants the tab's main\n  frame origin, and Chrome deliberately does not extend that grant to a frame\n  from another origin. `allFrames: true` therefore reaches the top frame and its\n  same-origin frames; injection into a cross-origin frame is refused, and the\n  refusal is per-frame, so the frames that *are* reachable still return their\n  buffers. A map that runs entirely inside a cross-origin `iframe` is thus\n  invisible to the scan, and the popup reports no services rather than an error.\n  Reaching one needs host permission for that origin — the standing access this\n  design exists to avoid — so the boundary is accepted.\n\nA service endpoint on its own is rarely enough to add a layer, so each result\nalso carries what the page asked that service *for*: the WMS `LAYERS` value, the\nWFS `typeName`, the WMTS layer, and — for a vector tileset, whose source layers\nlive in its style rather than its URL — the style document the page loaded from\nthe same origin. These arrive in GeoLibre as `serviceLayer` and `serviceStyle`\nand land in the matching form fields, so the dialog opens ready to submit rather\nthan on an endpoint with an empty layer field. Because one endpoint can serve\nmany layers, results are listed per layer (`WMS service: topp:states`) instead of\ncollapsing into a single entry per URL.\n\n## Sample websites for manual testing\n\nAfter loading or reloading the unpacked extension, open one of these websites in\na new tab, let its map finish drawing, then open the extension and confirm it\nlists the expected service. Selecting the result opens the matching GeoLibre Add\nData dialog with the service URL filled in. Each row below is a live third-party\nmap, so what it detects is what a real page actually requests.\n\n| Service | Website | Detected service | Layer carried over |\n| --- | --- | --- | --- |\n| WMS | [OpenLayers \"Image WMS\" example](https://openlayers.org/en/latest/examples/wms-image.html) | `https://ahocevar.com/geoserver/wms` | `topp:states` |\n| WMTS | [OpenLayers \"WMTS\" example](https://openlayers.org/en/latest/examples/wmts.html) | The `GetTile` request as a tile template | `sgmc2` |\n| WFS | [OpenLayers \"WFS\" example](https://openlayers.org/en/latest/examples/vector-wfs.html) | `https://ahocevar.com/geoserver/wfs` | `osm:water_areas` |\n| OGC API Features | [pygeoapi lakes collection](https://demo.pygeoapi.io/master/collections/lakes/items?f=html) | `https://demo.pygeoapi.io/master/collections/lakes/items` | — |\n| ArcGIS Feature Service | [OpenLayers \"Vector ESRI\" example](https://openlayers.org/en/latest/examples/vector-esri.html) | The `…/FeatureServer/0` layer URL | `0` |\n| XYZ raster tiles | [openstreetmap.org](https://www.openstreetmap.org/) | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | — |\n| Vector tiles, in a same-origin `iframe` | [MapLibre \"Display a map\" example](https://maplibre.org/maplibre-gl-js/docs/examples/display-a-map/) | `https://demotiles.maplibre.org/tiles/tiles.json` | style `…/style.json` |\n\nEach row above adds a layer that draws, with no further typing: that is the bar\nfor this table. A row that opens the dialog but leaves a required field empty is\na bug, not an expected extra step.\n\nThe MapLibre row is worth keeping in the set: the map runs inside a same-origin\n`iframe` (the docs page embeds `../display-a-map.html`), so it covers services a\npage reaches only through an embedded frame — the reachable kind, since a\ncross-origin frame is outside `activeTab` — and it renders\nthrough a worker, so it covers the tileset recovered from its TileJSON rather\nthan from a tile request. It also carries a style whose glyph ranges are served\nas `.pbf`; those are fonts, not a tileset, and must not be offered.\n\nOpening a service URL directly is detected too — the response is the page, so\n[a WMS GetCapabilities document](https://ows.terrestris.de/osm/service?SERVICE=WMS&REQUEST=GetCapabilities)\nlists `https://ows.terrestris.de/osm/service`.\n\nA document's buffer is its own and is discarded when the tab navigates, so a page\nwith no services (`https://example.com`) comes up empty rather than inheriting\nthe page before it.\n\nAfter selecting a result, add the layer in GeoLibre and verify that the browser\nconsole does not report a CORS error while fetching the service.\n\nRemote servers must allow GeoLibre to fetch the selected URLs through CORS.\nComplete HTTP(S) URLs, including signed query parameters, are forwarded to\nGeoLibre. Cookies and other browser-session credentials are not forwarded, so\ncookie-bound or session-authenticated links may fail. Temporary `blob:` and\nbrowser-internal links cannot be transferred.\n","isInternal":false,"tokens":2386,"sizeBytes":9924},{"name":"README.md","path":"packages/embed/README.md","rawUrl":"https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/README.md","title":"embed Documentation","category":"plugin-manifest","format":"markdown","content":"# embed Documentation\n\nPath: `packages/embed/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/README.md)","tokens":300,"sizeBytes":168}],"systemPromptSnippet":"<yakaai_skills repo=\"opengeos/GeoLibre\">\n<!-- File: docs/architecture.md (Tokens: ~11609 | Category: project-spec) -->\n# GeoLibre Architecture\n\n## Overview\n\nGeoLibre is a free and open-source, lightweight, cloud-native GIS platform that runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks, all from a single npm workspaces monorepo. The UI is a React app that ships as a native desktop app hosted by Tauri v2 and as a browser-based web app, adapting responsively to mobile and small screens. Four engines can render a map: MapLibre GL JS (the default), Mapbox GL JS, CesiumJS, and the ArcGIS Maps SDK for JavaScript. deck.gl supplies advanced raster, point-cloud, and 3D overlays where an engine supports them. Application state lives in a Zustand store (`@geolibre/core`). See the user-facing [rendering-engine guide](user-guide/rendering-engines.md) and the detailed [MapLibre](maplibre-renderer.md), [Mapbox](mapbox-renderer.md), [Cesium](cesium-renderer.md), and [ArcGIS](arcgis-renderer.md) references.\n\n```mermaid\nflowchart LR\n  UI[React UI] --> Store[Zustand Store]\n  Store --> Engine[MapEngine interface]\n  Engine --> ML[MapLibre GL JS]\n  Engine --> MB[Mapbox GL JS]\n  Engine --> CZ[CesiumJS]\n  Engine --> AG[ArcGIS Maps SDK]\n  Plugins[Built-in plugins] --> Store\n  Plugins --> Engine\n  UI --> Tauri[Tauri Plugins]\n  Tauri --> FS[File System]\n  Proc[Processing] --> Store\n  Proc -.-> Sidecar[FastAPI optional]\n```\n\n## Packages\n\n| Package                | Responsibility                                                                                 |\n| ---------------------- | ---------------------------------------------------------------------------------------------- |\n| `@geolibre/core`       | Domain types, project JSON schema, global store                                                |\n| `@geolibre/map`        | Rendering-engine lifecycles and adapters, layer sync, GeoJSON, raster, tile, MBTiles, controls, and selection styling |\n| `@geolibre/ui`         | Shared UI primitives (shadcn-style)                                                            |\n| `@geolibre/processing` | Client-side algorithm registry                                                                 |\n| `@geolibre/plugins`    | Plugin interface and built-in plugins                                                          |\n| `@geolibre/embed`      | Typed, dependency-free client for the iframe embed API, published to npm                       |\n| `@geolibre/collab-core` | Transport-neutral collaboration relay core (wire protocol, permissions, payload validators) shared by the `workers/collab` and `workers/collab-node` relays |\n| `geolibre-desktop`     | Shell layout, Tauri I/O, composition                                                           |\n\n## State flow\n\n1. User adds data through the Add Data menu, the Tauri dialog, browser file picker, drag and drop, or a built-in plugin control.\n2. Local vector data is parsed directly or converted to GeoJSON with DuckDB-WASM Spatial, then passed to `addGeoJsonLayer` in the store.\n3. Tile, service, raster, ArcGIS, MBTiles, and plugin-backed layers create `GeoLibreLayer` records with source metadata and native MapLibre layer ids when applicable.\n4. The selected `MapEngine` subscribes to `layers` and reconciles them with its SDK. MapLibre uses `MapController.syncLayers`; Mapbox, Cesium, and ArcGIS use their engine-specific adapters. The layer-control glue shared by the two Style-Spec engines lives in `packages/map/src/layer-control-host.ts`.\n5. Style panel and layer panel updates change layer state, then map sync updates paint, visibility, opacity, ordering, and removal.\n6. Attribute table selections update the highlighted feature source and can zoom the map to the selected feature.\n7. Desktop save uses `projectFromStore` and writes `.geolibre` to disk. The earlier `.geolibre.json` name remains readable.\n\nEvery layer action replaces the `layers` array, so `useAppStore((s) => s.layers)` re-renders a component on any edit of any layer. Only subscribers that genuinely need every field of every layer (the map canvases' sync, the Layers panel) should do that. Everything else uses the narrow hooks in `packages/core/src/layer-selectors.ts`: `useLayer(id)` for one record, `useLayerIds()` for the id list, `useLayerSummaries()` for `{ id, name, type, visible, groupId }` rows, and `useLayersWhen(open)` for always-mounted dialogs and panels that need the full array only while open.\n\n`useAppStore` is one Zustand store, but its code is split into slices under `packages/core/src/store/` (project, map view, layers, layer groups, project content, processing, libraries, session, collaboration, capabilities, and the `ui` dialog flags), composed in `packages/core/src/store.ts`. Add a new field or action to the slice that owns its data; an action that must also update another slice's fields (as `removeLayer` scrubs references everywhere) reads and writes the whole state through `set`/`get`. Undo history (`store/undo-history.ts`) tracks only the fields `partializeHistory` lists (layers, layer groups, basemap, story map, comments, dashboard widgets, legend). Everything else, including the whole `ui` sub-state, the selection and the camera, never records an undo step and is never reverted by undo or redo. Secondary panes and the Print Layout also reference layers but stay untracked (panes carry their own camera and must match the untracked grid layout; the composer writes back a local copy), so each undo/redo step scrubs their references to layers the step removed.\n\n## Rendering-engine model\n\nThe Zustand store is renderer-neutral: it contains plain `GeoLibreLayer`\nrecords, preferences, and `MapViewState` values rather than objects owned by a\nmapping SDK. MapLibre, Mapbox, Cesium, and ArcGIS each implement the shared\n`MapEngine` interface for camera operations, capability checks, picking,\ncontrols, placement, extent drawing, and capture. `primaryRenderer` selects the\nmain canvas, while each `SecondaryMapView.viewKind` selects the engine for a\nsplit pane. Changing either value unmounts the previous engine and connects the\nreplacement to the same project state.\n\nCapabilities are explicit rather than assumed. The shell gates commands against\nthe active `MapEngine`; layers report engine compatibility; and plugins declare\nan `engines` list. This lets a project retain an unsupported layer or suspended\nplugin across a switch without asking every SDK to emulate every feature.\n\nInside `packages/map/src`, a file named for an engine (`arcgis-*`, `cesium-*`,\n`mapbox-*`, and MapLibre's `layer-sync`, `map-controller`, and `MapCanvas`)\nnever imports another engine's file. Code two engines share lives in a neutral\nmodule named for what it does (`pmtiles-archive.ts`, `protocol-tiles.ts`,\n`gl-style-compiler.ts`, `feature-style.ts`, and so on). The\n`local/no-cross-engine-imports` ESLint rule\n(`eslint-rules/no-cross-engine-imports.mjs`) enforces the boundary and lists\nthe classification.\n\n## 3D globe view (CesiumJS)\n\nThe default MapLibre map can be joined by — or replaced with — a 3D globe rendered with [CesiumJS](https://cesium.com/platform/cesiumjs/). Cesium subscribes to the same engine-neutral store described above, whether it owns the primary canvas or one pane in a mixed-engine grid. MapLibre remains the default; integrations that paint directly through the MapLibre style API still require MapLibre.\n\n- **Where it renders.** Two places, both drawing the same store state:\n  - **As a secondary pane.** Each pane in the map grid carries a 2D/3D toggle (`SecondaryMapView.viewKind`), so a globe sits beside the 2D map for comparison.\n  - **As the primary map** (issue #2217). **View → Rendering engine → Cesium** selects the globe through the store's `primaryRenderer`. `DesktopShell` mounts `PrimaryCesiumCanvas` in place of the previous engine's canvas, so that renderer is not merely hidden — it is unmounted and stops consuming GPU resources. Switching adds or removes no panes, and a multi-pane grid can still mix all four engines. `CesiumCanvas` distinguishes the two roles by whether it is given a `viewId`: with one it is a pane backed by a `secondaryMapViews` record (own camera, own visibility overrides); without one it _is_ the primary map, reading and writing the shared `mapView` directly and ignoring the `syncView` toggle (which exists to make panes follow the primary camera). Because every engine reads the same store, the camera, basemap, layers, groups, visibility, and opacity survive the switch untouched where supported.\n- **What the globe cannot do (yet).** Every tool that drives a `MapController` — the map context menu, legend, comments, story map, terrain settings, the ML panels, and the MapLibre-typed plugin API — is MapLibre-only. Rather than leave them broken, `DesktopShell` does not mount them while the globe owns the primary area, and the View menu greys out the items that drive a controller (zoom, viewport history, Reset Orientation, Set View, the Google Maps/Earth hand-offs) instead of letting them silently do nothing. Renderer-neutral, store-driven surfaces (the Layers panel, Style Manager, the attribute table, processing, the Dashboard) stay available. Layer kinds the globe cannot draw stay in the project and are tagged \"2D only\" in the Layers panel, and come back when MapLibre does. A renderer-neutral controller interface is the follow-up milestone in #2217.\n- **Enabling it.** The 2D/3D pane toggle and the Rendering engine menu are both offered whether or not a Cesium Ion token is configured — the globe draws the project basemap, which needs no token. A token adds Cesium World Terrain and supplies Ion World Imagery as the fallback for a basemap with no raster form; without one the view shows a one-line hint saying so. The token is resolved through `getCesiumIonToken()` (`@geolibre/core`), which reads `VITE_CESIUM_TOKEN`/`CESIUM_TOKEN` from the build **or** from a runtime override, so it can be set at build time (`CESIUM_TOKEN`; see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion)) or at runtime with no rebuild. Settings → Environment Variables has a dedicated masked **Cesium Ion token** field backed by device-local `DesktopSettings` (the OS credential store on desktop, localStorage on the web; never the shared project file); `useRuntimeEnvironmentVariables` projects it into `VITE_CESIUM_TOKEN` on the `window.__GEOLIBRE_RUNTIME_ENV__` global (empty values are not projected, so they cannot blank a build-time token). The shared `useCesiumIonToken()` hook re-resolves the token on the `geolibre:runtime-env-change` event and both mount sites key the globe on it, so a newly entered token takes effect without a reload.\n- **Basemap.** `basemapToCesiumImagery()` (`@geolibre/core`) translates the project's `basemapStyleUrl` into a plain descriptor of what the globe should draw, and `applyBasemapImagery()` (`packages/map/src/cesium-basemap.ts`) turns that into imagery layers. The split keeps the decision engine-free and beside the basemap catalogs it reads, so the 2D and 3D renderers share one source of truth. Planetary and regional basemaps are already raster tile sets and carry straight over (TMS sources get `{y}` swapped for Cesium's `{reverseY}`, and a hybrid basemap's labels overlay rides directly above its imagery); GeoLibre's own vector styles map to a keyless raster basemap of matching tone, so a dark project basemap gives a dark globe; the blank basemap leaves a bare ellipsoid. Anything else — a provider style, a custom URL, an offline archive — has no raster form, and the renderer falls back to Ion World Imagery when a token is configured and OpenStreetMap when not. The basemap layers are inserted at the bottom of the imagery stack (index 0, the overlay at 1), which is what keeps them below the data layers `CesiumLayerSync` appends and raises to the top.\n- **Lazy loading.** The whole Cesium engine (~4.8 MB) is `import()`-ed only when a globe first mounts, kept in its own build chunk (`CODE_SPLITTING_GROUPS` in `vite.config.ts`) and off the 2D boot path. The build fails if Cesium, or more than 3 MB of JS, ends up in the app entry's static import graph (`bootBundleBudgetPlugin`). A Vite plugin (`vite-plugins/copy-cesium-assets.ts`) stages Cesium's runtime Workers/Assets/Widgets into `public/cesium/` and the canvas sets `window.CESIUM_BASE_URL` so the engine finds them.\n- **Drawing.** Both engines implement `drawExtent` with a shared pointer lifecycle and geographic extents. Cesium uses a draggable entity for manual placement and ground-clamped rectangles for extraction previews. Escape, pointer cancellation, focus loss, and renderer teardown restore navigation. The raster-subset panel uses these operations on both renderers; crossing extents retain west > east so the extractor can explain that they must be split. The basemap extraction panel shares the drawing helper, while its menu remains gated on vector-style support.\n- **Camera sync.** `packages/map/src/cesium-camera.ts` converts between MapLibre's Web-Mercator `MapViewState` (zoom, nadir-referenced pitch, bearing) and Cesium's camera (metric range, horizon-referenced pitch, heading), matched by **ground resolution** (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. `CesiumCanvas` seeds its camera from the shared `mapView`, applies store changes to the globe, and writes the globe's own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→`moveEnd` echo so there is no jitter loop.\n- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: \"cluster\"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package and reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285), streamed by view like EPT below (issue #2261), and plain LAS/LAZ files (issue #2261), which have no octree to sample and so are downloaded whole (up to 512 MB, decoded size included), decoded with the same package's LAS reader, thinned to every n-th point within the same budget, and reprojected through their GeoTIFF GeoKeys or, failing those, their WKT or the one the LiDAR control recorded. A compound WKT (a projected CRS paired with a vertical one, which proj4 cannot parse) is reduced to its horizontal part, and heights are converted to metres from the vertical unit, else the projected CRS's linear unit, the LAS convention for state-plane clouds in feet. A cloud with no CRS at all is refused rather than read as degrees. COPC archives and Entwine Point Tile datasets (`ept.json`, issue #2261), which run to tens of billions of points for a USGS 3DEP county, are streamed by view rather than sampled once (`cesium-point-cloud-stream.ts`, fed by `openCopcSource` and `openEptSource`; COPC's octree cube and root spacing give the same geometry EPT's manifest states): when the camera settles, the octree nodes that intersect the view are chosen most-needed first, by how coarse each node's point spacing looks at its distance from the camera, so a tilted view refines the ground underfoot before the horizon; the root always stays as the cloud's outline, a refresh only drops old nodes once the new ones are shown, decoded nodes are cached (3 million points) so panning back is free, and at most 1.5 million points are held as primitives. Subtree hierarchy files load when the walk reaches them, LASzip nodes (64 MB cap each) are fetched four at a time and decoded with the same LAS reader, and the CRS comes from the manifest's EPSG code (through the GeoKey path, for its unit) or its WKT, with an inverse projection to find which nodes a view covers; `binary` and `zstandard` datasets are refused. All three decoders drop ASPRS noise (classes 7 and 18) and stretch the height ramp over the 1st to 99th percentile of the decoded heights, so a few outliers do not flatten the colours — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. Zarr layers draw through zarr-cesium's `ZarrLayerProvider` (`cesium-zarr-imagery.ts`, issue #2261), fed from the same record the 2D Zarr control and the ArcGIS renderer read (the URL, a registered store or kerchunk manifest, the variable, selector, colour limits, and ramp); a Time Slider step or restyle updates the live provider and swaps in a fresh `ImageryLayer` over it rather than reopening the store, and a grid in a CRS other than EPSG:4326/3857 is reported as a layer error. zarr-cesium imports the `cesium` wrapper, which Vite aliases to `@cesium/engine` so the globe keeps one engine instance. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles, and ArcGIS vector tiles with a resolved style) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. ArcGIS vector-tile imports persist their resolved sources and prefixed style layers, allowing the headless sync to recreate them after a renderer switch or project reload and apply visibility, filters, and opacity while preserving service colors; the Style panel offers only opacity, order, zoom range, and filters for those layers rather than color editors that would not reach them. Older ArcGIS records without that resolved style remain 2D-only and must be re-added. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, legacy `arcgis` records without a resolved style, local point-cloud files the LiDAR control read from disk (no URL to fetch), EPT datasets in `binary` or `zstandard` form, raw `.ply`/`.splat` splat files, and the screen-space deck.gl viz kinds (heatmap, screen grid, contour) are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those \"2D only\". Deck.gl Layer builder records have no deck.gl on the globe; `cesium-deck-viz.ts` (issue #2261) rewrites each one, memoized per record and per data array so an opacity drag reuses the data source, into a record the globe already draws: scatterplot, icon, and text rows become points (fixed-size dots, pin markers, or labels), arcs become 3D parabolas over the great circle while lines and great circles stay on the ground, trips draw their whole paths without the trail animation, hexagon and grid rows are binned in JS into polygons coloured from the 2D ramp and extruded by the same 0..1000 m elevation range times the elevation scale, a GeoJSON viz passes through with its extrusion property, and glTF model rows become a CZML document of `model` packets with their altitude, bearing, and scale. The Style panel's colour and line width reach the globe the way the 2D overlay bridges them. Native KML/KMZ documents use `KmlDataSource`, preserving document styling, network links, and overlays; visibility and opacity are applied without replacing the original color properties. Local KMZ archives persist as data URLs. The Elevation Profile plugin draws native entities and samples the active terrain provider, rejecting results when that provider changes.\n- **Globe interaction parity.** The primary globe publishes ground cursor coordinates and optional terrain elevation to the shared status bar, clearing them on pointer exit and teardown. Projected scene modes report a Mercator projection. Home, scene-mode, and fullscreen widgets participate in shared control positioning without remounting their DOM. In the Controls menu they answer to the existing entries: **Compass** governs the Home (reset view) button and **Globe** governs the scene-mode picker, as the globe's counterparts of the 2D reset-bearing and projection controls, and the toolbar replays those choices onto a freshly mounted globe.\n- **Environment plugins.** The Sun simulation, Atmospheric Effects, Clouds, Precipitation, and Flight Simulator plugins declare Cesium alongside the 2D engines (Sun, Effects, and the Flight Simulator list MapLibre, Mapbox, and Cesium; Clouds and Precipitation also list ArcGIS) and branch on `app.getCesiumScene()` (issue #2287), a typed handle to the primary globe's namespace, widget, scene, camera, and clock that `CesiumEngine.getCesiumScene()` exposes as the globe's counterpart to `getMap()`. On the globe the Sun drives a native `SunLight`, `globe.enableLighting`, and the scene clock (the night-side depth maps onto `globe.vertexShadowDarkness`); Effects toggles the sky box and atmosphere and re-tints `SkyAtmosphere` by hue/saturation/brightness shifts from the halo settings; the Flight Simulator shares its keyboard, physics, and HUD with both 2D maps behind a camera adapter — `camera.setView` each frame with `screenSpaceCameraController.enableInputs` suspended on the globe, `setFreeCameraOptions` on Mapbox, `calculateCameraOptionsFromCameraLngLatAltRotation` on MapLibre; the weather overlays are store tile layers and render through `CesiumLayerSync` unchanged. The Spinning Globe control needs no branch: it drives the store through the control host's MapLibre facade. `DesktopShell` re-attaches these plugins after either engine mounts, so a renderer swap rebinds them the way a MapLibre re-init does.\n- **Plugin controls.** `CesiumControlHost` (`packages/map/src/cesium-control-host.ts`) hands a plugin control the same recording map the ArcGIS host does (issue #3088): `createShadowStyle` records its Style Spec calls, a store layer that mirrors one of its layers (`controlLayerMirrors`, including MapLibre's derived `layer-<id>-fill` ids) is drawn by `CesiumLayerSync`, and every other GeoJSON layer is evaluated by `shadowOverlayGraphics` (`shadow-overlay.ts`) and drawn as ground-clamped entities by `cesium-control-overlay.ts` in a host-owned `CustomDataSource`, with rhumb arcs so a straight 2D segment stays straight and points and labels past the horizon hidden. Layer-scoped events come from the shared `control-layer-events.ts`. Custom layers throw, so a control that needs MapLibre's WebGL context fails to mount rather than mounting dark; the host's COG helpers import rasters as store `cog` records instead (`native-raster-import.ts`). Because Cesium cancels `pointerdown`, the host re-dispatches the `mousedown`/`mousemove`/`mouseup` events the browser then drops, for controls that draw by dragging. `getControlMap(app)` resolves this map through `getCesiumControlMap`, and plugins still opt in with `engines`.\n- **Persistence.** A pane's `viewKind` is part of `SecondaryMapView` and round-trips through the `.geolibre.json` project format (`normalizeSecondaryMapViews`), so a project saved with a globe pane reopens as the globe, token or not. The workspace's own choice persists as the top-level `primaryRenderer` (`normalizePrimaryRenderer`), written only when it is not the default — so a MapLibre project is byte-identical to one saved before the setting existed, and a 3D-first project reopens directly on the globe.\n\n## DuckDB-WASM\n\nVector file import uses DuckDB-WASM for formats that need conversion before MapLibre can render them:\n\n```sql\nINSTALL spatial;\nLOAD spatial;\n```\n\nGeoParquet is read with DuckDB's Parquet reader after loading Spatial. Other local vector formats are passed to Spatial `ST_Read` when the WebAssembly extension can load the GDAL-backed reader. Zipped Shapefiles are parsed with `shpjs` first, then DuckDB Spatial is tried if that parser cannot read the file. KML files (and the KML inside unzipped KMZ archives) are read by an in-house parser that preserves embedded symbology, emitting [simplestyle-spec](https://github.com/mapbox/simplestyle-spec) properties (`fill`, `stroke`, `stroke-width`, and so on) so styled KML renders the way it does in Google Earth; KML the parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. A vector layer whose features carry simplestyle properties is rendered per-feature: paint expressions read the per-feature color/width and fall back to the flat layer style.\n\n### Apache Iceberg\n\nIceberg tables are read on the same engine, with DuckDB's core `iceberg` extension loaded alongside Spatial (`INSTALL iceberg; LOAD iceberg;`). A table addressed by its metadata location is scanned with `iceberg_scan()`; an Iceberg REST catalog is `ATTACH`ed under a fixed alias, enumerated with `SHOW ALL TABLES`, and the chosen table read as a qualified name. Either generated statement is only a default: the dialog pre-fills it into a SQL box, and whatever is in that box becomes the scan's source, so a `WHERE`, a join, or a projection can decide which geometries are rendered. A custom statement is checked with `cleanStatement`/`containsMultipleStatements` (the literal-aware helpers in `sql-workspace.ts`) before being wrapped as a sub-select, so a pasted script fails with a clear message rather than a parse error pointing at the generated wrapper.\n\nGeometry handling is deliberately narrower than the vector-file loader's. Only columns DuckDB reports as native `GEOMETRY` are candidates: Iceberg v3 has a real geometry type — `iceberg_column_definition.cpp` maps it to `LogicalType::GEOMETRY` — so a BLOB or VARCHAR here is an ordinary attribute, and the blob/base64-WKB fallbacks that make sense for plain Parquet would only produce loads that fail inside `ST_AsGeoJSON`. **The CRS comes from the schema, not the user.** DuckDB renders a CRS-annotated column as `GEOMETRY(<crs>)`, so `icebergTransformCrs` reads it and reprojects to WGS84 through `ST_Transform`; a column carrying no CRS parameter is `OGC:CRS84` by specification (the same constant DuckDB's extension hard-codes as `IcebergConstants::DefaultGeometryCRS`), which is already GeoJSON's lon/lat convention and passes through untransformed. Nothing about the coordinate system is persisted on the layer, so a table re-projected upstream stays correct on the next reload.\n\nThe work is split between `lib/iceberg.ts`, which holds the persisted layer config, the SQL builders, and the table-selection rule with no DuckDB dependency, and `lib/iceberg-loader.ts`, which owns the engine. The loader runs on the **SQL Workspace's** dedicated DuckDB instance rather than the shared one: an Iceberg scan reads the table's Parquet data files over HTTP, so it needs that instance's pre-spatial warm-up and its poisoned-instance recovery (see `resetSqlDatabase`).\n\nTwo consequences of Iceberg tables being large are wired into the app rather than left to the user. Every load is capped by a row limit, and the total row count — a `count(*)` over the configured source, which DuckDB can answer from the manifest metadata for an unfiltered table, though a custom statement's filters, joins, or projections have to be evaluated — is shown before anything is materialized. And the resulting layer, though refreshable on demand, is excluded from timer-driven refresh by `supportsAutoRefresh` in `lib/layer-refresh.ts` — re-scanning a table of that size on an interval is never what the user meant, so the automatic-refresh interval is unavailable for it rather than merely defaulted off.\n\n## Advanced Add Data workflows\n\nThe Add Data surface includes native dialogs for XYZ, WMS, WFS, vector files (via the Add Vector dialog backed by `maplibre-gl-vector`), GeoJSON URLs, vector tile sources, delimited text, raster tile templates, COG and GeoTIFF rasters (via the Add Raster dialog backed by `maplibre-gl-raster`), MBTiles, ArcGIS FeatureServer or VectorTileServer layers, Apache Iceberg tables, and GPX waypoints, tracks, and routes. It also supports 3D Tiles layers, Cesium Ion assets by id (a tileset or imagery layer with `metadata.sourceKind: \"cesium-ion\"`, offered when the globe is the primary renderer and badged \"3D only\" on the 2D map; `packages/core/src/cesium-ion.ts`, issue #2290), WFS and GeoJSON URL refresh, text marker labels, and multiple DuckDB SQL query-result layers with identify, selection, and attribute table support. The Components plugin wraps `maplibre-gl-components` panels for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, then mirrors added layers into the GeoLibre store so the Layer panel, project format, and layer control can reason about them. Additional data sources are available through the Planetary Computer and Earth Engine panels, the Overture Maps plugin, and the federal Web Services plugins. The Time Slider plugin, backed by `maplibre-gl-time-slider`, animates time series raster and vector data (COG, XYZ/WMTS, WMS-Time, and time-filtered GeoJSON) through a docked timeline, mirroring each source it adds into the GeoLibre store as an external native layer.\n\nLocal MBTiles tiles are read through a custom MapLibre protocol backed by Tauri commands. Remote rasters are fetched through the desktop backend when needed, and the local development server includes a raster proxy for selected release assets that need CORS handling.\n\n## Python sidecar\n\nThe FastAPI app in `backend/geolibre_server` backs the format Conversion tools, the raster tools, and the optional server engine for the Whitebox toolbox through a managed local processing sidecar. The desktop app starts the sidecar on demand, communicates over `127.0.0.1`, and keeps the heavier Python processing stack outside the browser bundle.\n\nThe Whitebox toolbox does **not** depend on it: its 1,000+ tools run in the browser on the `geolibre-wasm` WebAssembly runtime by default, and the sidecar is an opt-in alternative engine that can read native file paths for batch runs over a directory.\n\nThe Vector tools (Processing → GeoLibre Toolbox → Vector) run client-side with Turf.js and need no sidecar. All of the tools can optionally run on the sidecar's `/vector` endpoints, backed by GeoPandas and Shapely, for projection-aware results; the sidecar reports availability through `/vector/status`, and the dialog falls back to the client engine when the optional `vector` extra is not installed.\n\nA third Vector engine, **Python (Pyodide)**, runs the same GeoPandas/Shapely code **in the browser** via [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly), so the GeoPandas path is available on the web build with no server. The geometry logic is a framework-free module, `backend/geolibre_server/geolibre_server/vector_ops.py`, that both the sidecar and the browser run — a Vite plugin (`vite-plugins/copy-vector-ops.ts`) copies it into the app bundle, and a classic Web Worker (`public/pyodide/pyodide-worker.js`) loads Pyodide from a CDN, installs `geopandas`, and calls `run_vector_tool` over a JSON-string boundary. One source of truth means the Sidecar and Pyodide engines return identical results. The Pyodide runtime is downloaded lazily on first use; the `VITE_PYODIDE_INDEX_URL` env var points it at a self-hosted mirror for offline/production deployments.\n\nThe **SQL Workspace** offers an **Apache Sedona** engine alongside DuckDB and PostGIS. It runs Sedona spatial SQL on [SedonaDB](https://sedona.apache.org/sedonadb/), the single-node Rust (DataFusion + Arrow) engine, through the sidecar's `/sql` endpoints (`/sql/status`, `/sql/run`) backed by the optional `sedona` extra (`apache-sedona[db]`). In the browser — and on desktop when the extra is not installed — the same engine runs entirely client-side on [CereusDB](https://github.com/tobilg/cereusdb), a WebAssembly build of SedonaDB; the workspace prefers the sidecar when reachable and falls back to CereusDB automatically. The CereusDB bundle is large, so it is dynamically imported into its own chunk and only downloaded when a Sedona query first runs.\n\nThe sidecar has since grown past conversion. Its routers live in `backend/geolibre_server/geolibre_server/app/`: `raster.py` runs the Raster toolbox on rasterio/numpy/contourpy, `vector.py` backs the optional GeoPandas vector engine, `sql.py` backs the Sedona engine above, `postgis.py` and `mssql.py` load PostGIS and SQL Server tables as editable layers and write edits back, `pointcloud.py` writes point cloud annotator labels into a whole LAS/LAZ/COPC file, and `ml.py` reverse-proxies AI Segmentation to the `samgeo-api` server (SamGeo/SAM 3), which is kept out of process because SAM 3 needs a multi-gigabyte GPU Torch build. See the [Server API reference](server-api.md) for every endpoint. Leafmap and GeoAI workflows are still expected to follow the same pattern.\n\n## Offline support (PWA)\n\nThe standalone web build is an installable Progressive Web App. `vite-plugin-pwa` (configured in `apps/geolibre-desktop/vite.config.ts`) emits a web manifest plus a Workbox service worker, and `src/main.tsx` registers it next to `installStaleChunkReload` so the two coordinate. The service worker is built only for the web build; it is disabled for the Tauri desktop build (already offline via bundled assets) and the embedded Jupyter wheel (`GEOLIBRE_EMBED=1`), where `registerSW` resolves to a no-op.\n\nCaching is split to keep the first visit light:\n\n- **Precache (app shell).** The HTML and the JS/CSS chunks that boot the map are precached, so the shell loads with no network after the first visit. The heavy chunks below are excluded from the precache to avoid a large first-load download.\n- **Runtime cache, CacheFirst.** The content-hashed build assets the precache skips (everything under `/assets/`) are cached on first use: the **MapLibre** bundle, **DuckDB-WASM and its spatial extension**, and the MapLibre feature-plugin chunks. Hashed filenames make CacheFirst safe — a redeploy mints new URLs, so a stale entry is never served as current. This is what makes local-file workflows (DuckDB Spatial conversion) work offline after they have run online once. Self-hosting the spatial extension via `VITE_DUCKDB_SPATIAL_EXTENSION_PATH` keeps it same-origin so it is cached too. **PGlite/PostGIS** is not in this same-origin list: it is fetched from the jsDelivr CDN to keep it out of the build — ~25 MB raw, and ~22 MB of otherwise-incompressible weight in the Tauri binary — so the PostGIS SQL engine needs network on first use. A separate rule caches it thereafter (see below).\n- **Runtime cache, CDN engines.** A second CacheFirst rule (`geolibre-cdn-engines`) covers the heavy engines served from jsDelivr: **Pyodide** and its wheels, **PGlite/PostGIS**, the **CereusDB** (Apache Sedona) wasm, and `gdal3.js`. Their URLs embed the exact package version, so a redeploy mints new URLs and CacheFirst never serves a stale engine, and jsDelivr's permissive CORS headers mean these come back as normal (non-opaque) 200s that can be revalidated and evicted like any other entry. This is what lets the browser SQL (PostGIS), Sedona SQL, and Python features work offline **after their first online use**. When `GEOLIBRE_PGLITE_CDN=0` / `GEOLIBRE_CEREUS_CDN=0` those engines are bundled under `/assets/` instead and this rule simply never matches them; Pyodide is always CDN-loaded regardless. The rule also covers `/npm/@duckdb/`, which matches when `GEOLIBRE_DUCKDB_WASM_CDN=1` moves DuckDB off the origin (below) and when `maplibre-gl-duckdb` fetches its own engine, which it does from jsDelivr in every build.\n- **Basemaps.** Tiles and styles from the CORS-friendly default hosts (OpenFreeMap, CARTO) are runtime-cached. Other remote tiles, services, and ArcGIS/WMS/WFS sources stay network-only by design and are unavailable offline.\n\nSo each of these CDN engines needs the network on **first** use, then works offline. To remove even the first-use dependency, build with `GEOLIBRE_PGLITE_CDN=0` and `GEOLIBRE_CEREUS_CDN=0`, which vendor PGlite/PostGIS and the CereusDB wasm back into the build under `/assets/`, where the same-origin rule covers them (PGlite alone re-adds ~22 MB to the Tauri binary).\n\nTo strip **all GeoLibre-controlled** external CDN references from the build output — for deployments that cannot load any resource from untrusted CDNs — use `GEOLIBRE_NO_EXTERNAL_CDN=1`. This implies `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0`, and additionally disables features whose code references external CDNs: storymap HTML export (which injects `<script>` tags from `unpkg.com`), built-in object detection models (YOLO ONNX weights from `cdn.jsdelivr.net`), Segment Everything, 3D Tiles Draco/KTX2 decoders, and GDAL export. Pyodide remains available when `VITE_PYODIDE_INDEX_URL` points to an approved mirror. Runtime `deployment.json` configures client policy; it does not change this build-time CDN packaging.\n\n**DuckDB-WASM goes the other way**, because it is on the critical path for opening a local vector file and so is bundled by default. `GEOLIBRE_DUCKDB_WASM_CDN=1` moves it to jsDelivr instead — or `npm run lite:build`, which sets that flag and then _asserts_ no emitted file exceeds the ceiling, so a regression fails the build rather than the upload. It is pinned to the installed version by duckdb-wasm's own `getJsDelivrBundles()`, so the fetched engine cannot drift from the loader compiled into the bundle. The flag exists for one reason: `duckdb-mvp.wasm` (~40 MB) and `duckdb-eh.wasm` (~35 MB) both exceed the **25 MiB per-asset limit on Cloudflare Pages and Workers static assets**, which rejects the upload outright, and nothing else in the build comes close (the next largest is ~22 MB). So this single flag decides whether the web build can be hosted there at all — it takes the output from ~251 MB to ~176 MB with no file over the ceiling. GitHub Pages allows 100 MB per file and needs none of this. The flag is ignored for both targets that ship no service worker: a Tauri build, which must stay offline-capable, and an embed build (`GEOLIBRE_EMBED=1`), where the engine would otherwise be refetched every notebook session with no runtime cache behind it. Neither has the size ceiling this exists for, since a binary and a wheel are not uploaded to Cloudflare. A CDN-hosted worker script cannot be passed to `new Worker` (it must be same-origin), so that variant wraps it in a same-origin blob that `importScripts` the real one; this needs `worker-src blob:` and the CDN in `script-src`, both already in `docker/nginx.conf`. The nested `importScripts` is matched against `script-src` rather than `worker-src` — verified against that policy in Chromium and Firefox, since Firefox has historically checked worker sub-resources against `worker-src`/`child-src`.\n\nTwo engines have no such escape hatch. **Pyodide** is always fetched: `VITE_PYODIDE_INDEX_URL` re-points it at a mirror you host — worth doing when jsDelivr is unreachable or disallowed — but note that neither CacheFirst rule matches such a mirror, since one is scoped to `cdn.jsdelivr.net` and the other to same-origin `/assets/`, so unless the mirror is served from under `/assets/` it falls back to ordinary HTTP caching rather than the service worker. **`gdal3.js`** — which backs the Georeferencer's client-side GeoTIFF/COG export — is never vendored at all: its wasm (~28 MB) and data (~12 MB) only ever come from the CDN, and `GEOLIBRE_GDAL_CDN=0` opts out of that rather than bundling them, leaving the loader with no paths and that export unavailable.\n\nThe **desktop** build ships no service worker, so none of this runtime caching applies there: it re-fetches every CDN-loaded engine on first use per install, unless PGlite/PostGIS and CereusDB are bundled with the flags above.\n\nA new deploy is picked up via `registerType: \"autoUpdate\"`: the new service worker installs in the background and takes control (`skipWaiting` + `clientsClaim`), so its fresh precache serves subsequent requests. Workbox's default force-reload-on-activation is deliberately suppressed via `onNeedReload` in `src/main.tsx` — on the relative-base `/demo/` subpath that reload fired spuriously and discarded in-progress map state. Page recovery is delegated to `installStaleChunkReload`, which reloads on-demand only when an orphaned lazy chunk 404s (cooldown-guarded; if `sessionStorage` is blocked it skips the reload and lets the preload error surface). Precached chunks are served from cache and never 404, so the page is no longer reloaded out from under the user.\n\n## Container image\n\nThe root Dockerfile packages the browser app: a Node build stage runs the\n`geolibre-desktop` workspace build, then copies `apps/geolibre-desktop/dist`\ninto nginx. The nginx config serves static assets and falls back to `index.html`\nfor browser-entry URLs.\n\nAt startup, the entrypoint validates the selected source policy, applies\nnonblank environment overrides, generates nginx guards, conditionally starts\nthe Python sidecar, and serves the generated public `/deployment.json`. The\nbrowser fetches that policy before first render, then resolves settings from\npolicy, runtime environment and build environment, in that order. See\n[Deployment Policy](deployment-policy.md) and\n[Self-Hosting: container policy enforcement](self-hosting.md#container-policy-enforcement).\n\nThe `Publish Container Image` workflow builds the image for pull requests and\npublishes it to GitHub Container Registry for pushes to `main`, version tags,\nand manual runs. The upstream image name is `ghcr.io/opengeos/geolibre`.\n\nThe image bundles the optional Python sidecar (uvicorn) and reverse-proxies it\nat `/sidecar`; it starts only when the final policy grants `processing:run` or\n`data:add` and `GEOLIBRE_DISABLE_SIDECAR` is not `1`. The container does not\nrun the Tauri desktop shell, so workflows that depend on desktop filesystem\naccess still require the installed desktop app. Client policy and nginx guards\ndo not enforce desktop processing, browser WASM, separately exposed sidecars,\nor plugin execution.\n\n## Security\n\n- The Tauri CSP pins `script-src` to version-specific jsDelivr paths and keeps\n  `connect-src` open to `https:`/`http:` for user-supplied data URLs; see\n  [Desktop CSP `script-src` allowlist](maintenance.md#desktop-csp-script-src-allowlist).\n- File access uses dialog-selected paths only.\n\n### Credential storage\n\nTokens and API keys saved in Settings (share token, Cesium Ion token, Mapbox\ntoken, ArcGIS API key, the secret fields of AI Assistant profiles), saved\nPostGIS connection strings, and the Share.GeoLibre OAuth refresh token live in\ndifferent places per build:\n\n- **Desktop (Tauri, macOS/Windows/Linux):** the OS credential store (macOS\n  Keychain, Windows Credential Manager, Linux Secret Service) under the service\n  `org.geolibre.desktop`, one entry per credential, through the\n  `secure_store_*` commands in `src-tauri/src/secure_store.rs`. The\n  `geolibre.desktopSettings` localStorage blob keeps those fields empty, and\n  `geolibre.postgres.connectionIds` holds only the non-secret order of the\n  `postgres.connection.<uuid>` entries. The index is written before a new\n  entry, so a crash never leaves an unreferenced credential; a startup that\n  finds an indexed id without a credential (its write failed) drops the id and\n  shows the warning. `lib/credential-hydration.ts` loads them into memory\n  before the app renders and migrates any plaintext values left in\n  localStorage by an older build, removing each only after its keychain write\n  succeeds. That load is the only read: `secure_store_get_many` answers once\n  per page load of a webview (the reload hook in `lib.rs` reopens it), and\n  `secure_store_seal`, which the external-plugin loader calls before importing\n  a plugin, closes it without reading, so plugin code cannot pull saved tokens\n  from the store (issue #2858). Writes and deletes stay open. The OAuth refresh token is stored per issuer as\n  `share.oauth.refreshToken.<issuer>` and rotates on every refresh; the access\n  token and the PKCE verifier stay in memory. Because the server revokes a\n  session when a consumed refresh token is presented, the issuer is recorded\n  in the non-secret `geolibre.share.oauth.unsavedIssuers` list before each\n  keychain write and removed once the latest queued write for it completes. A\n  launch that finds its issuer listed deletes the stored token and starts\n  signed out rather than trusting a possibly consumed one. If the list itself\n  cannot be written, the new token is not stored: a delete of the stored copy\n  is queued and the sign-in lasts only until GeoLibre closes. After a refresh\n  that delete is best effort; quitting before it finishes leaves the consumed\n  token stored, so the next launch signs out on its first refresh instead of\n  at startup. Sign-out waits for its delete before reporting signed out, and\n  sends the revoke request whether or not the delete succeeds. If the delete\n  fails, Settings reports the sign-out as incomplete; the delete stays queued\n  and is retried only with a later credential write while the app runs. After\n  a restart the token is discarded if its issuer is on the unsaved list; if\n  that list could not be written either, the token is restored, and it still\n  works only if the revoke request also failed.\n  Credentials a project file would otherwise carry (geocoding API keys\n  `preferences.geocoding.apiKeys` and secret Environment Variables) are\n  stored device-wide by name as `project.geocoding.apiKey.<providerId>` and\n  `project.env.<VARIABLE>`; the non-secret\n  `geolibre.projectCredentials.accounts` list indexes them, with the same\n  index-first write order. Saving a project moves geocoding keys and uniquely\n  named secret environment values to the keychain, leaving empty values in\n  the file. Duplicate or nameless secret environment rows cannot be assigned\n  a unique account and remain for the local keep/strip save prompt.\n  Settings writes a value only when the user changes it. Clearing a field\n  deletes the stored value only if the fi\n\n<!-- File: extensions/geolibre-chrome/manifest.json (Tokens: ~267 | Category: anthropic-skill) -->\n{\n  \"manifest_version\": 3,\n  \"name\": \"Open data in GeoLibre\",\n  \"description\": \"Find geospatial datasets and services used by the current page and open them in GeoLibre.\",\n  \"version\": \"0.4.0\",\n  \"homepage_url\": \"https://geolibre.app/\",\n  \"minimum_chrome_version\": \"106\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"Open data in GeoLibre\",\n    \"default_popup\": \"popup.html\",\n    \"default_icon\": {\n      \"16\": \"icons/geolibre-16.png\",\n      \"32\": \"icons/geolibre-32.png\"\n    }\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": false\n  },\n  \"icons\": {\n    \"16\": \"icons/geolibre-16.png\",\n    \"32\": \"icons/geolibre-32.png\",\n    \"48\": \"icons/geolibre-48.png\",\n    \"128\": \"icons/geolibre-128.png\"\n  },\n  \"content_security_policy\": {\n    \"extension_pages\": \"script-src 'self'; object-src 'self'\"\n  }\n}\n\n<!-- File: extensions/geolibre-chrome/PRIVACY.md (Tokens: ~678 | Category: anthropic-skill) -->\n# Privacy policy — Open data in GeoLibre\n\nLast updated: August 18, 2026\n\nOpen data in GeoLibre does not independently collect, retain, or sell personal\ninformation, page contents, or usage analytics. It does forward the complete\ndataset, service, and style URLs that the user explicitly selects,\nas described below. The only data it stores is an optional custom GeoLibre\nbase URL, described in the settings section below.\n\nThe extension uses Chrome's `activeTab` permission to inspect the current page\nonly after the user clicks the extension's toolbar icon. In that moment it reads\nthe page's links and structured metadata, and reads back the addresses of the\nrequests the page has already made, from the Resource Timing record each\ndocument keeps of its own loading, to identify geospatial services used by\ninteractive maps. It does not inspect response bodies, and it does not observe\nthe network or run in the background at any other time. Nothing is stored: the\nlist exists only while the popup is open and is discarded when it closes.\n\nWhen the user chooses **Open in GeoLibre**, the complete selected HTTP(S)\ndataset and style URLs are placed in the query string of a new tab opened on\nthe configured GeoLibre destination: `https://web.geolibre.app/` by default, or\nthe instance the user set on the settings page (see below), in which case that\ninstance receives the URLs instead. These URLs are forwarded verbatim and may\ncontain signed query parameters, access tokens, user identifiers, or other\npersonal data. Do not select a URL containing information you do not want to\nsend to that GeoLibre instance.\n\nThe navigation request exposes its URL and the user's IP address to GeoLibre's\nweb-hosting infrastructure, where standard service logs may retain them. The\nnavigation may also appear in browser history. See the current\n[GeoLibre privacy policy](https://geolibre.app/privacy/) for the service's data\npractices. The extension itself never persists a URL.\n\nThe extension does not fetch or upload the datasets. GeoLibre requests them\ndirectly from their original servers, subject to those servers' privacy\npolicies and CORS configuration. Cookies and other browser-session credentials\nfrom the source page are not forwarded, although credentials embedded directly\nin a selected URL are part of the URL and are forwarded.\n\nThe extension's settings page (open it from `chrome://extensions`) lets the\nuser replace the default `https://web.geolibre.app/` destination with a\ndifferent GeoLibre instance, for example a self-hosted one. That single URL is\nkept in Chrome's synced extension storage, which Chrome may sync across the\nuser's signed-in browsers under their own Google account; it is never sent\nanywhere by the extension itself except as the destination of the \"Open in\nGeoLibre\" tab it opens. Clearing it in the settings page or uninstalling the\nextension removes it.\n\nThe extension uses no remote code, advertising, analytics, tracking pixels,\ncookies, or accounts. It holds no host permissions and no permission to\nobserve browsing.\n\nQuestions may be submitted through the GeoLibre repository:\n<https://github.com/opengeos/GeoLibre/issues>.\n\n<!-- File: extensions/geolibre-chrome/STORE_LISTING.md (Tokens: ~864 | Category: anthropic-skill) -->\n# Chrome Web Store listing\n\nPublished listing:\n<https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj>\n\n## Name\n\nOpen data in GeoLibre\n\n## Summary\n\nFind geospatial datasets and map services on a webpage and open them in GeoLibre.\n\n## Detailed description\n\nOpen data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map.\n\nClick the extension icon to find supported data links on the current page, filter them by vector, raster, or LiDAR type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds (LAS, LAZ, COPC, and EPT), and ZIP archives containing GeoJSON.\n\nThe extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages.\n\nInteractive maps are supported too. The extension recognizes the WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests the current page has already made.\n\nThe extension reads the page only when you click its icon, holds no standing access to any website, runs nothing in the background, and stores only one setting: the GeoLibre address \"Open in GeoLibre\" uses, which you can point at your own instance. It runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it.\n\nDataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred.\n\n## Category\n\nDeveloper Tools\n\n## Language\n\nEnglish\n\n## Permission justification\n\nEach block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. As of 0.4.0 the manifest requests `activeTab`, `scripting`, and `storage`, so the webRequest and host-permission fields still do not appear.\n\n### activeTab\n\nactiveTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, LiDAR point clouds, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time.\n\n### scripting\n\nscripting injects two packaged functions into the active tab when the user opens the popup. One reads the page's links and metadata to find dataset files. The other reads back the addresses of the requests the page has already made, so the map services it draws can be recognized; a map fetches those from JavaScript, so they are never links in the document. Both functions are contained in the extension package, so no remote code is involved. They run once per invocation and return their results to the popup.\n\n### storage\n\nstorage holds a single setting: the base URL \"Open in GeoLibre\" opens, in case the user runs their own GeoLibre instance instead of the hosted default. It is read only when the popup opens and only written from the extension's own settings page. No other data is stored.\n\n### Not requested\n\nThe extension requests no host permissions, and no permission to watch network requests or run in the background. It does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. Answer \"No, I am not using remote code\": every script it runs ships inside the package.\n\n<!-- File: packages/collab-core/package.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Collab-core Skill\n\nPath: `packages/collab-core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/package.json)\n\n<!-- File: packages/collab-core/tsconfig.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Collab-core Skill\n\nPath: `packages/collab-core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/collab-core/tsconfig.json)\n\n<!-- File: packages/core/package.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Core Skill\n\nPath: `packages/core/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/package.json)\n\n<!-- File: packages/core/tsconfig.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Core Skill\n\nPath: `packages/core/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/core/tsconfig.json)\n\n<!-- File: packages/embed/package.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Embed Skill\n\nPath: `packages/embed/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/package.json)\n\n<!-- File: packages/embed/tsconfig.build.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Embed Skill\n\nPath: `packages/embed/tsconfig.build.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.build.json)\n\n<!-- File: packages/embed/tsconfig.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Embed Skill\n\nPath: `packages/embed/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/tsconfig.json)\n\n<!-- File: packages/map/package.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Map Skill\n\nPath: `packages/map/package.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/package.json)\n\n<!-- File: packages/map/tsconfig.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Map Skill\n\nPath: `packages/map/tsconfig.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/map/tsconfig.json)\n\n<!-- File: packages/plugins/package.json (Tokens: ~1527 | Category: anthropic-skill) -->\n{\n  \"name\": \"@geolibre/plugins\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./assistant-tool-registry\": \"./src/assistant-tool-registry.ts\",\n    \"./atl03\": \"./src/plugins/atl03.ts\",\n    \"./cog-spectral-profile\": \"./src/plugins/cog-spectral-profile.ts\",\n    \"./earthdata-relay\": \"./src/plugins/earthdata-relay.ts\",\n    \"./elevation-profile-chart\": \"./src/plugins/elevation-profile/chart/profileChart.ts\",\n    \"./geo-editor-geometry\": \"./src/plugins/geo-editor-geometry.ts\",\n    \"./lidar\": \"./src/plugins/components/lidar.ts\",\n    \"./local-netcdf\": \"./src/plugins/local-netcdf.ts\",\n    \"./maplibre-graticule\": \"./src/plugins/maplibre-graticule.ts\",\n    \"./maplibre-vantor/style.css\": \"./src/plugins/vantor/styles.css\",\n    \"./mgrs-reference\": \"./src/plugins/mgrs-reference.ts\",\n    \"./plugin-ids\": \"./src/plugin-ids.ts\",\n    \"./raster-symbology\": \"./src/plugins/raster-symbology.ts\",\n    \"./right-panel-registry\": \"./src/right-panel-registry.ts\",\n    \"./zarr-time-axis\": \"./src/plugins/zarr-time-axis.ts\"\n  },\n  \"dependencies\": {\n    \"@carbonplan/zarr-layer\": \"0.11.0\",\n    \"@deck.gl/aggregation-layers\": \"^9.4.0\",\n    \"@deck.gl/core\": \"^9.4.0\",\n    \"@deck.gl/geo-layers\": \"^9.4.0\",\n    \"@deck.gl/layers\": \"^9.4.0\",\n    \"@deck.gl/mapbox\": \"^9.4.0\",\n    \"@deck.gl/maplibre\": \"^9.4.0\",\n    \"@deck.gl/mesh-layers\": \"^9.4.0\",\n    \"@developmentseed/deck.gl-geotiff\": \"^0.8.1\",\n    \"@developmentseed/deck.gl-raster\": \"^0.8.1\",\n    \"@developmentseed/morecantile\": \"^0.8.1\",\n    \"@dvt3d/maplibre-three-plugin\": \"^1.7.1\",\n    \"@esri/maplibre-arcgis\": \"^1.3.1\",\n    \"@geolibre/core\": \"*\",\n    \"@geolibre/map\": \"*\",\n    \"@geoman-io/maplibre-geoman-free\": \"^0.10.1\",\n    \"@loaders.gl/i3s\": \"^4.5.4\",\n    \"@luma.gl/core\": \"^9.4.2\",\n    \"@luma.gl/engine\": \"^9.4.2\",\n    \"@luma.gl/webgl\": \"^9.4.2\",\n    \"@mapbox/vector-tile\": \"^3.0.0\",\n    \"@maplibre/maplibre-gl-directions\": \"^0.10.1\",\n    \"@strands-agents/sdk\": \"^1.21.0\",\n    \"@tauri-apps/api\": \"^2.12.4\",\n    \"@tauri-apps/plugin-opener\": \"^2.7.2\",\n    \"@turf/union\": \"^7.4.0\",\n    \"a5-js\": \"^1.0.0\",\n    \"cog-tiler-wasm\": \"^0.5.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"h3-js\": \"^4.5.0\",\n    \"h5wasm\": \"^0.10.3\",\n    \"hyparquet\": \"^1.31.4\",\n    \"hyparquet-compressors\": \"^1.1.2\",\n    \"icechunk-js\": \"^0.6.1\",\n    \"jspdf\": \"^4.2.1\",\n    \"lerc\": \"^4.2.0\",\n    \"mapillary-js\": \"^4.1.2\",\n    \"maplibre-gl\": \"^6.15.0\",\n    \"maplibre-gl-3d-tiles\": \"^0.5.11\",\n    \"maplibre-gl-basemap-control\": \"^0.14.4\",\n    \"maplibre-gl-components\": \"^0.31.2\",\n    \"maplibre-gl-duckdb\": \"^0.2.4\",\n    \"maplibre-gl-earth-engine\": \"^0.4.2\",\n    \"maplibre-gl-enviroatlas\": \"^0.1.1\",\n    \"maplibre-gl-esri-wayback\": \"^0.2.2\",\n    \"maplibre-gl-fema-wms\": \"^0.1.2\",\n    \"maplibre-gl-geo-editor\": \"^0.11.2\",\n    \"maplibre-gl-geoagent\": \"^0.6.1\",\n    \"maplibre-gl-lidar\": \"^0.21.0\",\n    \"maplibre-gl-nasa-earthdata\": \"^0.1.4\",\n    \"maplibre-gl-national-map\": \"^0.1.1\",\n    \"maplibre-gl-overture-maps\": \"^0.4.1\",\n    \"maplibre-gl-planetary-computer\": \"^0.4.0\",\n    \"maplibre-gl-raster\": \"^0.14.15\",\n    \"maplibre-gl-splat\": \"^0.2.9\",\n    \"maplibre-gl-streetview\": \"^0.8.0\",\n    \"maplibre-gl-swipe\": \"^0.14.0\",\n    \"maplibre-gl-time-slider\": \"^1.8.7\",\n    \"maplibre-gl-usgs-lidar\": \"^0.11.5\",\n    \"maplibre-gl-vector\": \"^0.11.1\",\n    \"mgrs\": \"^2.2.0\",\n    \"netcdfjs\": \"^4.0.0\",\n    \"ngeohash\": \"^0.6.4\",\n    \"open-location-code-typescript\": \"^1.5.0\",\n    \"pbf\": \"^5.1.2\",\n    \"pmtiles\": \"^4.5.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\",\n    \"satellite.js\": \"^7.1.0\",\n    \"shpjs\": \"^6.2.0\",\n    \"three\": \"^0.186.1\",\n    \"webdggrid\": \"^1.9.3\",\n    \"whitebox-wasm\": \"^0.6.1\",\n    \"zarrita\": \"^0.7.5\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"@types/ngeohash\": \"^0.6.8\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n\n<!-- File: packages/plugins/tsconfig.json (Tokens: ~32 | Category: anthropic-skill) -->\n{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"paths\": {}\n  },\n  \"include\": [\"src\"]\n}\n\n<!-- File: packages/processing/package.json (Tokens: ~581 | Category: anthropic-skill) -->\n{\n  \"name\": \"@geolibre/processing\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"dependencies\": {\n    \"@bjorn3/browser_wasi_shim\": \"^0.4.2\",\n    \"@geolibre/core\": \"*\",\n    \"@turf/area\": \"^7.4.0\",\n    \"@turf/bbox\": \"^7.4.0\",\n    \"@turf/bearing\": \"^7.4.0\",\n    \"@turf/boolean-contains\": \"^7.4.0\",\n    \"@turf/boolean-intersects\": \"^7.4.0\",\n    \"@turf/boolean-within\": \"^7.4.0\",\n    \"@turf/buffer\": \"^7.4.0\",\n    \"@turf/centroid\": \"^7.4.0\",\n    \"@turf/circle\": \"^7.4.0\",\n    \"@turf/convex\": \"^7.4.0\",\n    \"@turf/destination\": \"^7.3.5\",\n    \"@turf/difference\": \"^7.4.0\",\n    \"@turf/dissolve\": \"^7.4.0\",\n    \"@turf/distance\": \"^7.3.5\",\n    \"@turf/envelope\": \"^7.4.0\",\n    \"@turf/flatten\": \"^7.3.5\",\n    \"@turf/helpers\": \"^7.4.0\",\n    \"@turf/intersect\": \"^7.4.0\",\n    \"@turf/sector\": \"^7.4.0\",\n    \"@turf/simplify\": \"^7.4.0\",\n    \"@turf/tin\": \"^7.4.0\",\n    \"@turf/union\": \"^7.4.0\",\n    \"@turf/voronoi\": \"^7.4.0\",\n    \"dggal\": \"^0.0.6\",\n    \"fflate\": \"^0.8.3\",\n    \"geolibre-wasm\": \"^1.7.1\",\n    \"geotiff\": \"^3.0.5\",\n    \"geotiff-geokeys-to-proj4\": \"^2026.10.9\",\n    \"onnxruntime-web\": \"1.30.0\",\n    \"proj4\": \"^2.22.0\",\n    \"s2js\": \"^1.44.0\"\n  },\n  \"devDependencies\": {\n    \"@types/geojson\": \"^7946.0.16\",\n    \"typescript\": \"^7.0.2\"\n  }\n}\n\n<!-- File: packages/processing/tsconfig.json (Tokens: ~19 | Category: anthropic-skill) -->\n{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n\n<!-- File: packages/ui/package.json (Tokens: ~416 | Category: anthropic-skill) -->\n{\n  \"name\": \"@geolibre/ui\",\n  \"version\": \"3.4.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"main\": \"./src/index.ts\",\n  \"types\": \"./src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./globals.css\": \"./src/globals.css\",\n    \"./theme.css\": \"./src/theme.css\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.3.1\",\n    \"@radix-ui/react-direction\": \"^1.2.0\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.2.1\",\n    \"@radix-ui/react-label\": \"^2.1.17\",\n    \"@radix-ui/react-scroll-area\": \"^1.4.0\",\n    \"@radix-ui/react-separator\": \"^1.2.0\",\n    \"@radix-ui/react-slider\": \"^1.6.1\",\n    \"@radix-ui/react-slot\": \"^1.5.0\",\n    \"@radix-ui/react-tabs\": \"^1.2.1\",\n    \"@radix-ui/react-tooltip\": \"^1.4.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-react\": \"^1.58.0\",\n    \"react\": \"^19.3.1\",\n    \"react-dom\": \"^19.3.1\",\n    \"tailwind-merge\": \"^3.7.0\",\n    \"tw-animate-css\": \"^1.4.0\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.3.0\",\n    \"@types/react-dom\": \"^19.3.0\",\n    \"typescript\": \"^7.0.2\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  }\n}\n\n<!-- File: packages/ui/tsconfig.json (Tokens: ~19 | Category: anthropic-skill) -->\n{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"include\": [\"src\"]\n}\n\n<!-- File: CLAUDE.md (Tokens: ~2490 | Category: claude-rule) -->\n# CLAUDE.md\n\nGuidance for Claude Code (claude.ai/code) when working in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`,\n`workers/*`) plus two non-npm components: a Python FastAPI sidecar\n(`backend/geolibre_server`) and a separate Python package (`python/`, the\n`geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS\nworkspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2**\n(`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx\n(Docker), and embedded in Jupyter (the `python/` package bundles a build of the\nweb app into its wheel).\n\n## Commands\n\n```bash\nnpm run dev            # web dev server → http://localhost:5173\nnpm run tauri:dev      # desktop app (required for filesystem dialogs, local MBTiles, local raster reads)\nnpm run build          # production web build → apps/geolibre-desktop/dist/\nnpm run lite:build     # same, but DuckDB-WASM from jsDelivr — for hosts with a per-asset size cap\nnpm run tauri:build    # desktop installers → apps/geolibre-desktop/src-tauri/target/release/bundle/\nnpm run typecheck      # alias for the full build (tsc -b && vite build) — writes to dist/, not a pure type-check\nnpm run ci             # full local gate: lint + ci:frontend + typecheck:tests + ci:backend + check:rust\nnpm run ci:frontend    # build + frontend + worker tests (CI runs the halves as parallel jobs)\nnpm run ci:backend     # backend + docker/tests (needs Python and the npm CLI, no npm install)\n```\n\nTests:\n\n```bash\nnpm run test:frontend                              # node --test over tests/*.test.ts (tsx loader)\nnpm run test:frontend:coverage                     # same, plus a coverage summary (gated on a floor)\nnode --import tsx --test tests/<name>.test.ts      # a single frontend test file\nnpm run test:backend                               # pytest backend/geolibre_server/tests\nnpm run test:backend:coverage                      # same, plus pytest-cov (gated on a floor)\npython -m pytest backend/geolibre_server/tests/test_x.py::test_y   # a single backend test\nnpm run test:worker                                # typecheck workers/viewer\nnpm run test:e2e                                   # Playwright smoke tests (e2e/) against the built web app\nnpm run check:rust                                 # cargo check the Tauri crate\nnpm run typecheck:tests                            # tsc over tests/, ratcheted on an error-count baseline (docs/maintenance.md)\ncd python && pytest                                # the geolibre Python package's own suite\n```\n\nSetup notes that bite:\n\n- Install the backend **`test`** extra or the vector/raster/SQL/ML tests skip\n  themselves and CI is green but hollow:\n  `pip install -e \"backend/geolibre_server[test]\"`.\n- `npm run test:e2e` needs `npx playwright install chromium` once. It builds the\n  app, serves it with `vite preview`, and drives it with Playwright; add specs\n  under `e2e/`.\n- Coverage floors act as a **ratchet** and the frontend report only counts files\n  a test imports, so the first test for a big module can read as a regression.\n  See [Coverage floors](docs/maintenance.md#coverage-floors) before touching a\n  floor.\n\nThe `python/` package is built into a wheel via `npm run build:embed` (produces\n`apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its\nversion is dynamic, sourced from `python/src/geolibre/__init__.py`.\n\n## Pre-commit\n\n`.pre-commit-config.yaml` includes a **local `npm-build` hook**, so\n`pre-commit run` compiles the whole app — it is slow and can touch unrelated\nbuild state. Scope it to the files you changed:\n`pre-commit run --files <paths>`. Run it before pushing.\n\n## Architecture (the parts that span files)\n\nThe app is **store-driven**. `@geolibre/core` holds the Zustand store, domain\ntypes, and the `.geolibre.json` project schema — it is the single source of\ntruth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file\n   picker, drag-and-drop, or a plugin control.\n2. Local vector files MapLibre can't render directly are converted to GeoJSON\n   in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` →\n   `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs`\n   with a DuckDB fallback; KMZ unzipped client-side), then `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer`\n   records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers`\n   (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control.\n   **You don't mutate MapLibre directly from UI** — change store state and let\n   sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for\nraster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map`\n(MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) ·\n`@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins`\n(plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed\nclient) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** built-in plugins live in `packages/plugins/src/plugins/`, are\nexported from that package's `index.ts`, and registered in\n`apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips\nor a `plugin.json` manifest; bundled drop-ins under\n`apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop\nbuilds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`):\nbacks the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio).\nThe desktop app starts it on demand. It is **optional** — Vector tools run\nclient-side with Turf.js and only use the sidecar's `/vector` endpoints\n(GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog\nfalls back to the client engine via `/vector/status`. Optional extras:\n`conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are\namd64-only. The browser build proxies the sidecar at `/sidecar` (same-origin, no\nCORS), confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles\nuse a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script):\na headless stdio MCP server that authors `.geolibre.json` files. It is layered so\nnothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob),\n`authoring.py` *applies* them to a whole project (add/remove/restyle a layer,\nmove the camera, compose the legend/colorbar/swipe controls), and both `Map` and\nthe MCP tools delegate to `authoring.py`. `server.py` is the only module that\nimports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py`\nconfines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines\nto `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself\nwithout the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it\nand the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- CSP: `connect-src`/`img-src` allow any `https:` (desktop also `http:`), so a\n  new map/tile/service host needs no CSP change. `script-src` is pinned: the\n  desktop (`tauri.conf.json`) and web (`docker/nginx.conf`) CSPs list\n  version-specific jsDelivr paths that `tests/tauri-csp.test.ts` re-derives, so\n  a bump that moves a CDN script URL (or a new script host) must update both\n  files — see [`docs/maintenance.md`](docs/maintenance.md#desktop-csp-script-src-allowlist).\n  Map/tile-host CORS for selected release assets is handled by a dev-server\n  raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in\n  `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- UI strings are translatable via **react-i18next**; catalogs live in\n  `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of\n  truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings. The UI\n  mirrors for right-to-left locales, so style new components with Tailwind's\n  logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…),\n  not the physical `ml-`/`left-` forms. **Read `docs/i18n.md` in full before any\n  i18n work** — skipping it is the most common source of locale bugs.\n- **Some things upstream doesn't export are mirrored by hand, and drift fails\n  silently.** Before bumping `maplibre-gl`, any `maplibre-gl-*` package,\n  `geolibre-wasm`, or `@tauri-apps/plugin-http` — including Dependabot PRs — read\n  [`docs/maintenance.md`](docs/maintenance.md) and run the frontend suite. That\n  page also covers the coverage floors, the `npm audit` allowlist, the npm\n  publish layout for `@geolibre/core`/`@geolibre/map`, the committed\n  `backend/geolibre_server/uv.lock`, and the generated files (Whitebox menu\n  catalog, `npm run i18n:tools`, the `skills/geolibre/` agent skill) that must be\n  regenerated or updated in the same PR.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`,\n  `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/agent-skill.md`,\n  `docs/i18n.md`, `docs/maintenance.md`, `docs/contributing.md`.\n\n<!-- File: apps/geolibre-desktop/public/plugins/README.md (Tokens: ~300 | Category: plugin-manifest) -->\n# plugins Documentation\n\nPath: `apps/geolibre-desktop/public/plugins/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/apps/geolibre-desktop/public/plugins/README.md)\n\n<!-- File: extensions/geolibre-chrome/README.md (Tokens: ~2386 | Category: plugin-manifest) -->\n# Open data in GeoLibre\n\nA Manifest V3 Chrome extension that finds supported geospatial dataset links on\nthe current page, reads back the geospatial service requests its interactive\nmaps have already made, and opens selected data in GeoLibre.\n\n## Install\n\nThe published extension is on the Chrome Web Store:\n\n[Open data in GeoLibre](https://chromewebstore.google.com/detail/open-data-in-geolibre/joinecgbfoldanidcoakpjgkbaceaooj)\n\n## Install locally (development)\n\n1. Open `chrome://extensions`.\n2. Enable **Developer mode**.\n3. Choose **Load unpacked**.\n4. Select this `extensions/geolibre-chrome` directory.\n\nEverything happens after you click the toolbar icon: the extension scans the\ndocument's links, and reads each reachable frame's Resource Timing buffer to\nrecognize the\nservices its maps requested. It holds no permission beyond `activeTab`,\n`scripting`, and `storage`, and runs no background service worker. The only\nthing `storage` holds is an optional custom GeoLibre base URL, set from the\nextension's settings page; nothing else is stored.\n\n## Settings\n\nRight-click the toolbar icon and choose **Options** (or open\n`chrome://extensions`, find the extension, and choose **Extension options**) to\nset a different GeoLibre base URL, for example a self-hosted instance. Leaving\nit unset keeps the default, `https://web.geolibre.app/`.\n\n## Package for the Chrome Web Store\n\nFrom the repository root, run:\n\n```bash\nnpm run build:chrome-extension\n```\n\nThe versioned upload ZIP is written to `dist/`. Store listing copy and\npermission explanations are in `STORE_LISTING.md`; the publishable privacy\npolicy source is in `PRIVACY.md`. The policy must be hosted at a public URL\nbefore submitting the listing.\n\n## Discovery\n\nThe scanner recognizes GeoJSON/JSON, GeoParquet/Parquet, PMTiles, GeoTIFF/COG,\nLiDAR point clouds (LAS/LAZ/COPC and EPT `ept.json`), and ZIP links in anchors, linked resources, selected data attributes, and\nschema.org JSON-LD `contentUrl`/`downloadUrl` fields. Existing GeoLibre deep\nlinks are unpacked, and a neighboring `name.style.json` or\n`name.geolibre.style.json` link is paired with the matching dataset. A point\ncloud whose URL has no LAS/LAZ suffix, found only because its link or metadata\nsays COPC, LAZ, or LASzip, is opened with `dataType=lidar` so GeoLibre does not\nread it as GeoJSON.\n\nSource Cooperative repository pages receive additional handling: the extension\nreads the complete embedded file inventory even when the visible table is\nvirtualized, canonicalizes links to `data.source.coop`, and removes duplicate\npage/download links. The popup can filter discovered files by vector, raster,\nor LiDAR type without changing the current selection.\n\n## Services\n\nA map fetches its tiles and service documents from JavaScript, so they are never\nlinks in the page. What the extension reads instead is `performance\n.getEntriesByType(\"resource\")`, the record of its own requests that every\ndocument keeps, collected from the top frame and every same-origin frame below\nit. Recognized\nare WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, XYZ/TMS image\ntiles, and PBF/MVT vector tiles. Tile requests are collapsed into reusable\n`{z}/{x}/{y}` templates, and repeated requests from the same service appear once.\n\nThree consequences of reading the buffer rather than watching the network:\n\n- **Worker requests are invisible.** MapLibre and similar renderers fetch vector\n  tiles from a web worker, which records them in the worker's own timeline, not\n  the document's. Such a tileset is recovered from the metadata the main thread\n  *did* fetch: its TileJSON, or failing that the style document, which GeoLibre\n  can resolve a layer from on its own. A style is only offered in its own right\n  when no tileset from its origin was found.\n\n  A TileJSON is recognized by name (`tile.json`, `tiles.json`, `tilejson.json`),\n  which is narrower than every way a server can name one: tileserver-gl serves\n  `/data/<id>.json`, and that tileset is reached through its style rather than\n  its metadata. Nothing reads the response, so a TileJSON describing *raster*\n  tiles is indistinguishable from a vector one and is offered as a vector\n  tileset. Such a false positive cannot become a layer: Add Data resolves the\n  document on submit and refuses it when no source layers come out. Reading the\n  body here would mean fetching a cross-origin URL, which needs the host\n  permissions this design exists to avoid.\n\n  A style stands as a candidate of its own only when its path names it one:\n  `…/style.json`, `…/styles.json`, or an ArcGIS `…/resources/styles/<name>.json`.\n  The looser `…/styles/<name>.json` is an ordinary theme or configuration route\n  as well, so a style matched that way is still trusted to explain a tileset\n  found at its origin, but never offered on its own, where a page's theme file\n  would appear as a layer.\n- **The buffer is finite.** It holds 250 entries per document by default and\n  stops recording once full. A map's own early requests are normally well inside\n  that, but a very busy page can lose a service added late. Raising the limit\n  needs a `document_start` script, which needs the broad host permissions this\n  design exists to avoid, so the cap is accepted.\n- **Cross-origin frames are out of reach.** `activeTab` grants the tab's main\n  frame origin, and Chrome deliberately does not extend that grant to a frame\n  from another origin. `allFrames: true` therefore reaches the top frame and its\n  same-origin frames; injection into a cross-origin frame is refused, and the\n  refusal is per-frame, so the frames that *are* reachable still return their\n  buffers. A map that runs entirely inside a cross-origin `iframe` is thus\n  invisible to the scan, and the popup reports no services rather than an error.\n  Reaching one needs host permission for that origin — the standing access this\n  design exists to avoid — so the boundary is accepted.\n\nA service endpoint on its own is rarely enough to add a layer, so each result\nalso carries what the page asked that service *for*: the WMS `LAYERS` value, the\nWFS `typeName`, the WMTS layer, and — for a vector tileset, whose source layers\nlive in its style rather than its URL — the style document the page loaded from\nthe same origin. These arrive in GeoLibre as `serviceLayer` and `serviceStyle`\nand land in the matching form fields, so the dialog opens ready to submit rather\nthan on an endpoint with an empty layer field. Because one endpoint can serve\nmany layers, results are listed per layer (`WMS service: topp:states`) instead of\ncollapsing into a single entry per URL.\n\n## Sample websites for manual testing\n\nAfter loading or reloading the unpacked extension, open one of these websites in\na new tab, let its map finish drawing, then open the extension and confirm it\nlists the expected service. Selecting the result opens the matching GeoLibre Add\nData dialog with the service URL filled in. Each row below is a live third-party\nmap, so what it detects is what a real page actually requests.\n\n| Service | Website | Detected service | Layer carried over |\n| --- | --- | --- | --- |\n| WMS | [OpenLayers \"Image WMS\" example](https://openlayers.org/en/latest/examples/wms-image.html) | `https://ahocevar.com/geoserver/wms` | `topp:states` |\n| WMTS | [OpenLayers \"WMTS\" example](https://openlayers.org/en/latest/examples/wmts.html) | The `GetTile` request as a tile template | `sgmc2` |\n| WFS | [OpenLayers \"WFS\" example](https://openlayers.org/en/latest/examples/vector-wfs.html) | `https://ahocevar.com/geoserver/wfs` | `osm:water_areas` |\n| OGC API Features | [pygeoapi lakes collection](https://demo.pygeoapi.io/master/collections/lakes/items?f=html) | `https://demo.pygeoapi.io/master/collections/lakes/items` | — |\n| ArcGIS Feature Service | [OpenLayers \"Vector ESRI\" example](https://openlayers.org/en/latest/examples/vector-esri.html) | The `…/FeatureServer/0` layer URL | `0` |\n| XYZ raster tiles | [openstreetmap.org](https://www.openstreetmap.org/) | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | — |\n| Vector tiles, in a same-origin `iframe` | [MapLibre \"Display a map\" example](https://maplibre.org/maplibre-gl-js/docs/examples/display-a-map/) | `https://demotiles.maplibre.org/tiles/tiles.json` | style `…/style.json` |\n\nEach row above adds a layer that draws, with no further typing: that is the bar\nfor this table. A row that opens the dialog but leaves a required field empty is\na bug, not an expected extra step.\n\nThe MapLibre row is worth keeping in the set: the map runs inside a same-origin\n`iframe` (the docs page embeds `../display-a-map.html`), so it covers services a\npage reaches only through an embedded frame — the reachable kind, since a\ncross-origin frame is outside `activeTab` — and it renders\nthrough a worker, so it covers the tileset recovered from its TileJSON rather\nthan from a tile request. It also carries a style whose glyph ranges are served\nas `.pbf`; those are fonts, not a tileset, and must not be offered.\n\nOpening a service URL directly is detected too — the response is the page, so\n[a WMS GetCapabilities document](https://ows.terrestris.de/osm/service?SERVICE=WMS&REQUEST=GetCapabilities)\nlists `https://ows.terrestris.de/osm/service`.\n\nA document's buffer is its own and is discarded when the tab navigates, so a page\nwith no services (`https://example.com`) comes up empty rather than inheriting\nthe page before it.\n\nAfter selecting a result, add the layer in GeoLibre and verify that the browser\nconsole does not report a CORS error while fetching the service.\n\nRemote servers must allow GeoLibre to fetch the selected URLs through CORS.\nComplete HTTP(S) URLs, including signed query parameters, are forwarded to\nGeoLibre. Cookies and other browser-session credentials are not forwarded, so\ncookie-bound or session-authenticated links may fail. Temporary `blob:` and\nbrowser-internal links cannot be transferred.\n\n<!-- File: packages/embed/README.md (Tokens: ~300 | Category: plugin-manifest) -->\n# embed Documentation\n\nPath: `packages/embed/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/opengeos/GeoLibre/HEAD/packages/embed/README.md)\n</yakaai_skills>"}