{"owner":"opengeos","repo":"GeoLibre","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md"],"skills":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`, `workers/*`) plus two non-npm components: a Python FastAPI sidecar (`backend/geolibre_server`) and a separate Python package (`python/`, the `geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS workspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2** (`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx (Docker), and embedded in Jupyter (the `python/` package bundles a build of the web 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: build + frontend + worker + backend + rust check\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 per-file coverage summary (Node built-in)\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 a pytest-cov term-missing report\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\n```\n\nThe `:coverage` variants run the same suites and print a coverage summary; CI\nruns them so every build reports coverage. They are now **gated on a floor**:\n`test:frontend:coverage` fails below 78% lines / 78% branches / 63% functions,\nand `test:backend:coverage` fails below 55% (`--cov-fail-under`). The floors sit\na few points under the current numbers as a **ratchet** — regressions fail CI,\nand when coverage rises comfortably above a floor, raise the floor to lock in the\ngain. The frontend\nreport only counts files a test actually imports, so a module with no test does\nnot appear at all rather than as 0%.\n\nThat last point is the one that bites: writing the *first* test for a large\nuntested module reads as a coverage **regression**, because the module and\neverything it imports enter the denominator at once. GeoLibre#1784 added a test\nthat imported `usePlugins.ts` and so pulled in the whole built-in plugin\nregistry, 39 files, dropping function coverage 72.90% → 60.36% and reddening\n`main`. The fix is to test against a leaf module rather than to lower the floor\n(GeoLibre#1888 extracted `lib/plugin-layer-queries.ts`; `geo-editor-geometry.ts`\nin `@geolibre/plugins` is the same pattern). Check what a new test *transitively*\nimports before assuming a coverage drop means the code got worse.\n\n`test:frontend:coverage` runs through `scripts/coverage-check.mjs` rather than\ncalling `node --test` directly. Node still enforces all three floors; the wrapper\nonly re-measures once when **line** coverage alone comes up short with every test\npassing. Line coverage is nondeterministic on CI (GeoLibre#1889: two runs over\nbyte-identical sources reported 81.82% and 76.47%, 114 of 444 files differing on\nlines and *none* on branches or functions), and it is not reproducible locally on\neither Node 22 or 26. Branch and function shortfalls, and any test failure, fail\non the spot with no retry, so a real regression still fails fast. `classify()` is\nexported and covered by `tests/coverage-check.test.ts` — change the retry policy\nthere, not by loosening a floor. If the retry starts firing regularly, fix the\nmeasurement instead of widening the mitigation.\n\nThe backend coverage run (and `npm run ci`,\nwhich calls the `:coverage` variants) needs `pytest-cov` from the backend `dev`\nextra. Install the **`test`** extra to run the *full* backend suite — without\nthe optional engines (geopandas/rasterio/sedona/httpx) the vector/raster/SQL/ML\ntests skip themselves and CI is green but hollow:\n`pip install -e \"backend/geolibre_server[test]\"`.\n\n`npm run test:e2e` builds the web app, serves it with `vite preview`, and drives\nit with Playwright (`@playwright/test`). First run: `npx playwright install\nchromium`. The webServer reuses an already-running preview locally and rebuilds\nin CI; add specs under `e2e/`.\n\nDependencies are watched two ways: **Dependabot** (`.github/dependabot.yml`)\nopens grouped weekly update PRs for npm, pip (backend + `python/`), cargo, and\nActions, and the CI **`audit` job** runs `npm run audit:ci`\n(blocking) plus a non-blocking `pip-audit` of the resolved backend environment.\n`audit:ci` is `scripts/audit-check.mjs`, a thin wrapper over `npm audit\n--omit=dev` that still fails on every high/critical advisory *except* the ones\nlisted in its `ALLOWLIST`. The wrapper exists because plain `npm audit` cannot\naccept a single finding, so one unpatchable transitive advisory reddens every PR\nuntil upstream ships a fix — which for an unmaintained leaf package may be never.\nOnly allowlist an advisory when there is **no patched version to upgrade to** and\nthe vulnerable code is **unreachable from a GeoLibre runtime path**, and say why\non both counts in the entry. Anything upgradeable gets upgraded instead. Stale\nentries print a warning rather than failing, since the advisory database is a\nlive service and a transient omission must not redden an unrelated PR.\n\nThe `python/` package has its own pytest suite (`cd python && pytest`) and is built into a wheel via `npm run build:embed` (produces `apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its version 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 `pre-commit run` compiles the whole app — it is slow and can touch unrelated build state. Scope it to the files you changed: `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 types, and the `.geolibre.json` project schema — it is the single source of truth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file picker, drag-and-drop, or a plugin control.\n2. Local vector files that MapLibre can't render directly are converted to GeoJSON in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` → `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs` with a DuckDB fallback; KMZ unzipped client-side). The result calls `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer` records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers` (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control. **You don't mutate MapLibre directly from UI** — you change store state and let sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for raster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map` (MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) · `@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins` (plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed client, the one package published to npm — `.github/workflows/publish-embed.yml` publishes it on each GitHub Release, skipping a version already there) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** Built-in plugins live in `packages/plugins/src/plugins/`, are exported from that package's `index.ts`, and registered in `apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips or a `plugin.json` manifest; bundled drop-ins under `apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop builds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`): backs the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio). The desktop app starts it on demand. It is **optional** — Vector tools (Processing → Vector) run client-side with Turf.js and only use the sidecar's `/vector` endpoints (GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog falls back to the client engine via `/vector/status`. Optional extras: `conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are amd64-only.\n\nThe browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles use a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script): a headless stdio MCP server that authors `.geolibre.json` files. It is layered so nothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob), `authoring.py` *applies* them to a whole project (add/remove/restyle a layer, move the camera, compose the legend/colorbar/swipe controls), and both `Map` and the MCP tools delegate to `authoring.py` — so a change to how a control is composed lands in one place. `server.py` is the only module that imports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py` confines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines to `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself without the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it and the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- **`backend/geolibre_server/uv.lock` is committed** (the root `.gitignore` ignores `uv.lock` everywhere else and negates it for this one path). That project is bundled into the desktop installers and launched with `uv run --frozen --project <resource dir>` from `src-tauri/src/lib.rs` — a directory the user cannot write (`C:\\Program Files\\…`, `/usr/lib/GeoLibre Desktop/…`). Ship it lockless and uv resolves, then tries to *write* `uv.lock` there, fails with \"Permission denied\" and exits 2 — which reaches the user as \"Jupyter server exited before it was ready (exit code: 2)\" with the cause invisible. So: any edit to that `pyproject.toml`'s dependencies must land with a refreshed lock (`uv lock --project backend/geolibre_server`). CI's \"Check the bundled sidecar lockfile is in sync\" step (`uv lock --check`) fails if they drift.\n- Tauri CSP allowlists tile/style hosts (OpenFreeMap, CARTO) — new external map/tile hosts must be added there.\n- Map/tile-host CORS for selected release assets is handled by a dev-server raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- The Processing **menu** (`ProcessingMenu.tsx`) renders from a checked-in, auto-generated catalog, `apps/geolibre-desktop/src/lib/whitebox-menu-catalog.ts` (do not hand-edit). Whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — run `node scripts/gen-whitebox-menu-catalog.mjs` and commit the result, or new/renamed WASM tools silently miss the menu (the Processing **dialog** lists them dynamically, so the gap only shows in the menu).\n- `MAX_VECTOR_PMTILES_ZOOM` (`packages/processing/src/wasm-convert.ts`) mirrors the deepest zoom `vector_to_pmtiles` accepts (18 — past it the tool exits with `validation error: max_zoom must be <= 18`). The cap lives inside the WASM binary and is not exported, so whenever `geolibre-wasm` is bumped — including Dependabot PRs — re-check it. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. Note this is **not** the sidecar's cap: freestiler allows 24 (`MAX_PMTILES_ZOOM` in `ConversionDialog.tsx`, mirroring `backend/geolibre_server/geolibre_server/app/conversion.py`), and the dialog validates against whichever engine is about to run. `tests/wasm-convert.test.ts` (\"accepts the documented maximum zoom and rejects one deeper\") fails in CI if the mirror drifts, so running the frontend suite after a bump is enough to catch it.\n- `MAX_VECTOR_BYTES` (`packages/plugins/src/plugins/remote-file-formats.ts`) mirrors `MAX_REMOTE_FILE_BYTES`, an **internal, unexported** constant in `maplibre-gl-vector` (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so whenever `maplibre-gl-vector` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check `src/lib/utils/remote.ts` in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy. `remote-file-formats.ts` is the **single** home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (`source-coop-api.ts` re-exports it under its own names for compatibility).\n- `MAP_PANEL_SELECTOR` (`apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx`) mirrors the **rendered** control class names from `maplibre-gl-components` — `maplibre-gl-html-control`, `maplibre-gl-legend`, `maplibre-gl-colorbar` — so the Record Video \"Include map panels\" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately **not** the `*-gui-control` authoring editors. The classes are internal and unexported, so whenever `maplibre-gl-components` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check them against the rendered controls and update the selector if they moved. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.\n- `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the \"enforces an expected result type\" test in `tests/expressions.test.ts` fails if the shape stops being honored.\n- `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error.\n- UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/i18n.md`, `docs/contributing.md`.\n"},"files":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`, `workers/*`) plus two non-npm components: a Python FastAPI sidecar (`backend/geolibre_server`) and a separate Python package (`python/`, the `geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS workspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2** (`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx (Docker), and embedded in Jupyter (the `python/` package bundles a build of the web 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: build + frontend + worker + backend + rust check\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 per-file coverage summary (Node built-in)\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 a pytest-cov term-missing report\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\n```\n\nThe `:coverage` variants run the same suites and print a coverage summary; CI\nruns them so every build reports coverage. They are now **gated on a floor**:\n`test:frontend:coverage` fails below 78% lines / 78% branches / 63% functions,\nand `test:backend:coverage` fails below 55% (`--cov-fail-under`). The floors sit\na few points under the current numbers as a **ratchet** — regressions fail CI,\nand when coverage rises comfortably above a floor, raise the floor to lock in the\ngain. The frontend\nreport only counts files a test actually imports, so a module with no test does\nnot appear at all rather than as 0%.\n\nThat last point is the one that bites: writing the *first* test for a large\nuntested module reads as a coverage **regression**, because the module and\neverything it imports enter the denominator at once. GeoLibre#1784 added a test\nthat imported `usePlugins.ts` and so pulled in the whole built-in plugin\nregistry, 39 files, dropping function coverage 72.90% → 60.36% and reddening\n`main`. The fix is to test against a leaf module rather than to lower the floor\n(GeoLibre#1888 extracted `lib/plugin-layer-queries.ts`; `geo-editor-geometry.ts`\nin `@geolibre/plugins` is the same pattern). Check what a new test *transitively*\nimports before assuming a coverage drop means the code got worse.\n\n`test:frontend:coverage` runs through `scripts/coverage-check.mjs` rather than\ncalling `node --test` directly. Node still enforces all three floors; the wrapper\nonly re-measures once when **line** coverage alone comes up short with every test\npassing. Line coverage is nondeterministic on CI (GeoLibre#1889: two runs over\nbyte-identical sources reported 81.82% and 76.47%, 114 of 444 files differing on\nlines and *none* on branches or functions), and it is not reproducible locally on\neither Node 22 or 26. Branch and function shortfalls, and any test failure, fail\non the spot with no retry, so a real regression still fails fast. `classify()` is\nexported and covered by `tests/coverage-check.test.ts` — change the retry policy\nthere, not by loosening a floor. If the retry starts firing regularly, fix the\nmeasurement instead of widening the mitigation.\n\nThe backend coverage run (and `npm run ci`,\nwhich calls the `:coverage` variants) needs `pytest-cov` from the backend `dev`\nextra. Install the **`test`** extra to run the *full* backend suite — without\nthe optional engines (geopandas/rasterio/sedona/httpx) the vector/raster/SQL/ML\ntests skip themselves and CI is green but hollow:\n`pip install -e \"backend/geolibre_server[test]\"`.\n\n`npm run test:e2e` builds the web app, serves it with `vite preview`, and drives\nit with Playwright (`@playwright/test`). First run: `npx playwright install\nchromium`. The webServer reuses an already-running preview locally and rebuilds\nin CI; add specs under `e2e/`.\n\nDependencies are watched two ways: **Dependabot** (`.github/dependabot.yml`)\nopens grouped weekly update PRs for npm, pip (backend + `python/`), cargo, and\nActions, and the CI **`audit` job** runs `npm run audit:ci`\n(blocking) plus a non-blocking `pip-audit` of the resolved backend environment.\n`audit:ci` is `scripts/audit-check.mjs`, a thin wrapper over `npm audit\n--omit=dev` that still fails on every high/critical advisory *except* the ones\nlisted in its `ALLOWLIST`. The wrapper exists because plain `npm audit` cannot\naccept a single finding, so one unpatchable transitive advisory reddens every PR\nuntil upstream ships a fix — which for an unmaintained leaf package may be never.\nOnly allowlist an advisory when there is **no patched version to upgrade to** and\nthe vulnerable code is **unreachable from a GeoLibre runtime path**, and say why\non both counts in the entry. Anything upgradeable gets upgraded instead. Stale\nentries print a warning rather than failing, since the advisory database is a\nlive service and a transient omission must not redden an unrelated PR.\n\nThe `python/` package has its own pytest suite (`cd python && pytest`) and is built into a wheel via `npm run build:embed` (produces `apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its version 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 `pre-commit run` compiles the whole app — it is slow and can touch unrelated build state. Scope it to the files you changed: `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 types, and the `.geolibre.json` project schema — it is the single source of truth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file picker, drag-and-drop, or a plugin control.\n2. Local vector files that MapLibre can't render directly are converted to GeoJSON in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` → `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs` with a DuckDB fallback; KMZ unzipped client-side). The result calls `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer` records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers` (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control. **You don't mutate MapLibre directly from UI** — you change store state and let sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for raster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map` (MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) · `@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins` (plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed client, the one package published to npm — `.github/workflows/publish-embed.yml` publishes it on each GitHub Release, skipping a version already there) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** Built-in plugins live in `packages/plugins/src/plugins/`, are exported from that package's `index.ts`, and registered in `apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips or a `plugin.json` manifest; bundled drop-ins under `apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop builds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`): backs the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio). The desktop app starts it on demand. It is **optional** — Vector tools (Processing → Vector) run client-side with Turf.js and only use the sidecar's `/vector` endpoints (GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog falls back to the client engine via `/vector/status`. Optional extras: `conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are amd64-only.\n\nThe browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles use a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script): a headless stdio MCP server that authors `.geolibre.json` files. It is layered so nothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob), `authoring.py` *applies* them to a whole project (add/remove/restyle a layer, move the camera, compose the legend/colorbar/swipe controls), and both `Map` and the MCP tools delegate to `authoring.py` — so a change to how a control is composed lands in one place. `server.py` is the only module that imports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py` confines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines to `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself without the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it and the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- **`backend/geolibre_server/uv.lock` is committed** (the root `.gitignore` ignores `uv.lock` everywhere else and negates it for this one path). That project is bundled into the desktop installers and launched with `uv run --frozen --project <resource dir>` from `src-tauri/src/lib.rs` — a directory the user cannot write (`C:\\Program Files\\…`, `/usr/lib/GeoLibre Desktop/…`). Ship it lockless and uv resolves, then tries to *write* `uv.lock` there, fails with \"Permission denied\" and exits 2 — which reaches the user as \"Jupyter server exited before it was ready (exit code: 2)\" with the cause invisible. So: any edit to that `pyproject.toml`'s dependencies must land with a refreshed lock (`uv lock --project backend/geolibre_server`). CI's \"Check the bundled sidecar lockfile is in sync\" step (`uv lock --check`) fails if they drift.\n- Tauri CSP allowlists tile/style hosts (OpenFreeMap, CARTO) — new external map/tile hosts must be added there.\n- Map/tile-host CORS for selected release assets is handled by a dev-server raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- The Processing **menu** (`ProcessingMenu.tsx`) renders from a checked-in, auto-generated catalog, `apps/geolibre-desktop/src/lib/whitebox-menu-catalog.ts` (do not hand-edit). Whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — run `node scripts/gen-whitebox-menu-catalog.mjs` and commit the result, or new/renamed WASM tools silently miss the menu (the Processing **dialog** lists them dynamically, so the gap only shows in the menu).\n- `MAX_VECTOR_PMTILES_ZOOM` (`packages/processing/src/wasm-convert.ts`) mirrors the deepest zoom `vector_to_pmtiles` accepts (18 — past it the tool exits with `validation error: max_zoom must be <= 18`). The cap lives inside the WASM binary and is not exported, so whenever `geolibre-wasm` is bumped — including Dependabot PRs — re-check it. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. Note this is **not** the sidecar's cap: freestiler allows 24 (`MAX_PMTILES_ZOOM` in `ConversionDialog.tsx`, mirroring `backend/geolibre_server/geolibre_server/app/conversion.py`), and the dialog validates against whichever engine is about to run. `tests/wasm-convert.test.ts` (\"accepts the documented maximum zoom and rejects one deeper\") fails in CI if the mirror drifts, so running the frontend suite after a bump is enough to catch it.\n- `MAX_VECTOR_BYTES` (`packages/plugins/src/plugins/remote-file-formats.ts`) mirrors `MAX_REMOTE_FILE_BYTES`, an **internal, unexported** constant in `maplibre-gl-vector` (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so whenever `maplibre-gl-vector` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check `src/lib/utils/remote.ts` in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy. `remote-file-formats.ts` is the **single** home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (`source-coop-api.ts` re-exports it under its own names for compatibility).\n- `MAP_PANEL_SELECTOR` (`apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx`) mirrors the **rendered** control class names from `maplibre-gl-components` — `maplibre-gl-html-control`, `maplibre-gl-legend`, `maplibre-gl-colorbar` — so the Record Video \"Include map panels\" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately **not** the `*-gui-control` authoring editors. The classes are internal and unexported, so whenever `maplibre-gl-components` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check them against the rendered controls and update the selector if they moved. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.\n- `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the \"enforces an expected result type\" test in `tests/expressions.test.ts` fails if the shape stops being honored.\n- `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error.\n- UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/i18n.md`, `docs/contributing.md`.\n"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","title":"CLAUDE.md","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Repository shape\n\nGeoLibre is a single **npm workspaces monorepo** (`apps/*`, `packages/*`, `workers/*`) plus two non-npm components: a Python FastAPI sidecar (`backend/geolibre_server`) and a separate Python package (`python/`, the `geolibre` Jupyter anywidget). One `npm install` at the root wires up every JS workspace. Use **npm** (the repo tracks `package-lock.json`), Node **22+**.\n\nThe same React app ships three ways: native desktop via **Tauri v2** (`apps/geolibre-desktop/src-tauri`), a browser web build served by nginx (Docker), and embedded in Jupyter (the `python/` package bundles a build of the web 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: build + frontend + worker + backend + rust check\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 per-file coverage summary (Node built-in)\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 a pytest-cov term-missing report\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\n```\n\nThe `:coverage` variants run the same suites and print a coverage summary; CI\nruns them so every build reports coverage. They are now **gated on a floor**:\n`test:frontend:coverage` fails below 78% lines / 78% branches / 63% functions,\nand `test:backend:coverage` fails below 55% (`--cov-fail-under`). The floors sit\na few points under the current numbers as a **ratchet** — regressions fail CI,\nand when coverage rises comfortably above a floor, raise the floor to lock in the\ngain. The frontend\nreport only counts files a test actually imports, so a module with no test does\nnot appear at all rather than as 0%.\n\nThat last point is the one that bites: writing the *first* test for a large\nuntested module reads as a coverage **regression**, because the module and\neverything it imports enter the denominator at once. GeoLibre#1784 added a test\nthat imported `usePlugins.ts` and so pulled in the whole built-in plugin\nregistry, 39 files, dropping function coverage 72.90% → 60.36% and reddening\n`main`. The fix is to test against a leaf module rather than to lower the floor\n(GeoLibre#1888 extracted `lib/plugin-layer-queries.ts`; `geo-editor-geometry.ts`\nin `@geolibre/plugins` is the same pattern). Check what a new test *transitively*\nimports before assuming a coverage drop means the code got worse.\n\n`test:frontend:coverage` runs through `scripts/coverage-check.mjs` rather than\ncalling `node --test` directly. Node still enforces all three floors; the wrapper\nonly re-measures once when **line** coverage alone comes up short with every test\npassing. Line coverage is nondeterministic on CI (GeoLibre#1889: two runs over\nbyte-identical sources reported 81.82% and 76.47%, 114 of 444 files differing on\nlines and *none* on branches or functions), and it is not reproducible locally on\neither Node 22 or 26. Branch and function shortfalls, and any test failure, fail\non the spot with no retry, so a real regression still fails fast. `classify()` is\nexported and covered by `tests/coverage-check.test.ts` — change the retry policy\nthere, not by loosening a floor. If the retry starts firing regularly, fix the\nmeasurement instead of widening the mitigation.\n\nThe backend coverage run (and `npm run ci`,\nwhich calls the `:coverage` variants) needs `pytest-cov` from the backend `dev`\nextra. Install the **`test`** extra to run the *full* backend suite — without\nthe optional engines (geopandas/rasterio/sedona/httpx) the vector/raster/SQL/ML\ntests skip themselves and CI is green but hollow:\n`pip install -e \"backend/geolibre_server[test]\"`.\n\n`npm run test:e2e` builds the web app, serves it with `vite preview`, and drives\nit with Playwright (`@playwright/test`). First run: `npx playwright install\nchromium`. The webServer reuses an already-running preview locally and rebuilds\nin CI; add specs under `e2e/`.\n\nDependencies are watched two ways: **Dependabot** (`.github/dependabot.yml`)\nopens grouped weekly update PRs for npm, pip (backend + `python/`), cargo, and\nActions, and the CI **`audit` job** runs `npm run audit:ci`\n(blocking) plus a non-blocking `pip-audit` of the resolved backend environment.\n`audit:ci` is `scripts/audit-check.mjs`, a thin wrapper over `npm audit\n--omit=dev` that still fails on every high/critical advisory *except* the ones\nlisted in its `ALLOWLIST`. The wrapper exists because plain `npm audit` cannot\naccept a single finding, so one unpatchable transitive advisory reddens every PR\nuntil upstream ships a fix — which for an unmaintained leaf package may be never.\nOnly allowlist an advisory when there is **no patched version to upgrade to** and\nthe vulnerable code is **unreachable from a GeoLibre runtime path**, and say why\non both counts in the entry. Anything upgradeable gets upgraded instead. Stale\nentries print a warning rather than failing, since the advisory database is a\nlive service and a transient omission must not redden an unrelated PR.\n\nThe `python/` package has its own pytest suite (`cd python && pytest`) and is built into a wheel via `npm run build:embed` (produces `apps/geolibre-desktop/dist-embed`, consumed by `python/hatch_build.py`). Its version 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 `pre-commit run` compiles the whole app — it is slow and can touch unrelated build state. Scope it to the files you changed: `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 types, and the `.geolibre.json` project schema — it is the single source of truth. Data flows one way:\n\n1. Data enters through the Add Data menus, Tauri dialogs, the browser file picker, drag-and-drop, or a plugin control.\n2. Local vector files that MapLibre can't render directly are converted to GeoJSON in-browser by **DuckDB-WASM Spatial** (`INSTALL spatial; LOAD spatial;` → `ST_Read`; GeoParquet via the Parquet reader; zipped Shapefiles via `shpjs` with a DuckDB fallback; KMZ unzipped client-side). The result calls `addGeoJsonLayer`.\n3. Tile/service/raster/ArcGIS/MBTiles/plugin layers become `GeoLibreLayer` records.\n4. `MapCanvas` subscribes to `layers`; `MapController.syncLayers` (`@geolibre/map`) reconciles MapLibre sources/layers and the layer control. **You don't mutate MapLibre directly from UI** — you change store state and let sync apply it.\n\nRendering is MapLibre GL JS in the webview, with **deck.gl** for raster/point-cloud/3D overlays.\n\n**Packages:** `@geolibre/core` (types, project format, store) · `@geolibre/map` (MapLibre lifecycle + layer sync) · `@geolibre/ui` (shadcn-style primitives) · `@geolibre/processing` (client-side algorithm registry) · `@geolibre/plugins` (plugin interface + built-in plugins) · `@geolibre/embed` (typed iframe embed client, the one package published to npm — `.github/workflows/publish-embed.yml` publishes it on each GitHub Release, skipping a version already there) · `geolibre-desktop` (shell layout, Tauri I/O, composition).\n\n**Plugins:** Built-in plugins live in `packages/plugins/src/plugins/`, are exported from that package's `index.ts`, and registered in `apps/geolibre-desktop/src/hooks/usePlugins.ts`. External plugins load from zips or a `plugin.json` manifest; bundled drop-ins under `apps/geolibre-desktop/public/plugins/<id>/` bake into both web and desktop builds. See `docs/plugin-api.md`.\n\n**Python sidecar** (`backend/geolibre_server`, FastAPI on `127.0.0.1:8765`): backs the Whitebox toolbox, format Conversion tools, and Raster tools (rasterio). The desktop app starts it on demand. It is **optional** — Vector tools (Processing → Vector) run client-side with Turf.js and only use the sidecar's `/vector` endpoints (GeoPandas/Shapely) when the optional `vector` extra is installed; the dialog falls back to the client engine via `/vector/status`. Optional extras: `conversion`, `vector`, `raster`. Some conversions (PMTiles, Whitebox) are amd64-only.\n\nThe browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles use a custom MapLibre protocol backed by Tauri commands.\n\n**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script): a headless stdio MCP server that authors `.geolibre.json` files. It is layered so nothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob), `authoring.py` *applies* them to a whole project (add/remove/restyle a layer, move the camera, compose the legend/colorbar/swipe controls), and both `Map` and the MCP tools delegate to `authoring.py` — so a change to how a control is composed lands in one place. `server.py` is the only module that imports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py` confines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines to `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself without the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it and the server ships untested.\n\n## Conventions\n\n- Never commit directly to `main`; branch and open a PR.\n- **`backend/geolibre_server/uv.lock` is committed** (the root `.gitignore` ignores `uv.lock` everywhere else and negates it for this one path). That project is bundled into the desktop installers and launched with `uv run --frozen --project <resource dir>` from `src-tauri/src/lib.rs` — a directory the user cannot write (`C:\\Program Files\\…`, `/usr/lib/GeoLibre Desktop/…`). Ship it lockless and uv resolves, then tries to *write* `uv.lock` there, fails with \"Permission denied\" and exits 2 — which reaches the user as \"Jupyter server exited before it was ready (exit code: 2)\" with the cause invisible. So: any edit to that `pyproject.toml`'s dependencies must land with a refreshed lock (`uv lock --project backend/geolibre_server`). CI's \"Check the bundled sidecar lockfile is in sync\" step (`uv lock --check`) fails if they drift.\n- Tauri CSP allowlists tile/style hosts (OpenFreeMap, CARTO) — new external map/tile hosts must be added there.\n- Map/tile-host CORS for selected release assets is handled by a dev-server raster proxy.\n- For MapLibre control styling fixes, add scoped overrides in `apps/geolibre-desktop/src/index.css`, never edit `node_modules`.\n- The Processing **menu** (`ProcessingMenu.tsx`) renders from a checked-in, auto-generated catalog, `apps/geolibre-desktop/src/lib/whitebox-menu-catalog.ts` (do not hand-edit). Whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — run `node scripts/gen-whitebox-menu-catalog.mjs` and commit the result, or new/renamed WASM tools silently miss the menu (the Processing **dialog** lists them dynamically, so the gap only shows in the menu).\n- `MAX_VECTOR_PMTILES_ZOOM` (`packages/processing/src/wasm-convert.ts`) mirrors the deepest zoom `vector_to_pmtiles` accepts (18 — past it the tool exits with `validation error: max_zoom must be <= 18`). The cap lives inside the WASM binary and is not exported, so whenever `geolibre-wasm` is bumped — including Dependabot PRs — re-check it. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. Note this is **not** the sidecar's cap: freestiler allows 24 (`MAX_PMTILES_ZOOM` in `ConversionDialog.tsx`, mirroring `backend/geolibre_server/geolibre_server/app/conversion.py`), and the dialog validates against whichever engine is about to run. `tests/wasm-convert.test.ts` (\"accepts the documented maximum zoom and rejects one deeper\") fails in CI if the mirror drifts, so running the frontend suite after a bump is enough to catch it.\n- `MAX_VECTOR_BYTES` (`packages/plugins/src/plugins/remote-file-formats.ts`) mirrors `MAX_REMOTE_FILE_BYTES`, an **internal, unexported** constant in `maplibre-gl-vector` (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so whenever `maplibre-gl-vector` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check `src/lib/utils/remote.ts` in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy. `remote-file-formats.ts` is the **single** home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (`source-coop-api.ts` re-exports it under its own names for compatibility).\n- `MAP_PANEL_SELECTOR` (`apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx`) mirrors the **rendered** control class names from `maplibre-gl-components` — `maplibre-gl-html-control`, `maplibre-gl-legend`, `maplibre-gl-colorbar` — so the Record Video \"Include map panels\" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately **not** the `*-gui-control` authoring editors. The classes are internal and unexported, so whenever `maplibre-gl-components` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check them against the rendered controls and update the selector if they moved. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.\n- `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the \"enforces an expected result type\" test in `tests/expressions.test.ts` fails if the shape stops being honored.\n- `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error.\n- UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`.\n- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/i18n.md`, `docs/contributing.md`.\n","category":"root","tokens":4289}]}