Skip to content

Changelog ​

All notable changes to @geoleaf/core are documented here.

Format: Keep a Changelog — Semantic Versioning.


[3.19.0] - 2026-10-23 ​

Changed ​

  • The built-in basemaps no longer join a profile's. street, topo and satellite were registered on every Baselayers.init, before the profile's basemaps: a profile that declared two offered five, the built-in three with their English labels — among them an imagery served from a third-party host the profile had not chosen. They are now registered only when the profile declares no basemap. An integrator who relied on them without declaring them, next to basemaps of their own, must now declare them. A declared entry named street, topo or satellite that brings no url, tiles or style still takes the built-in one's tiles and overrides only what it carries (label, apiKey…). One that brings its own source is taken as is: it used to be merged with the built-in one, whose tiles won over the declared url.
  • Without a defaultBasemap, the first declared basemap is active. The boot fell back to street, which a profile that declares its basemaps no longer has. The configuration guide already said "the first of the list".

Added ​

  • themeable, a key of a layer's definition. false puts the layer out of the themes' reach: applying a theme neither hides it for not listing it, nor changes its visibility or style when it lists it. For a technical layer whose visibility no theme decides — a clustering support, a computation layer — that a theme switched off for not listing it, and that nothing switched back on when it had no row in the layer manager. Absent or true, nothing changes.
  • --gl-color-scheme (light, dark under .gl-theme-dark), read by the layer manager's selects as their color-scheme, so that the browser draws their lists in GeoLeaf's scheme and not the host page's.

Fixed ​

  • A host page's generic rules no longer repaint GeoLeaf's fields and the basemap credit. Every GeoLeaf rule lives in a gl.* cascade layer, and an unlayered rule beats every layered one: a dark host theme's select { color: …; background: transparent } left light text on the layer manager's light panel — 1.24:1 for the basemap select and the style selects. The credit inherited the host's light text on the engine's half-white box. A new sheet, the only one outside the layers, hands the colour and background of GeoLeaf's fields back to GeoLeaf's layers (revert-layer); a host rule that names a GeoLeaf class, and gl.overrides, still apply. The credit now carries the engine's link colour, and the basemap select its own colours.
  • @geoleaf-plugins/table 1.2.2. The layer selector's two placeholders were hard-coded in English, in an otherwise translated table: they now come from the plugin's dictionaries, with a new key, ui.table.no_visible_layer, in its six languages. And the panel no longer covers the host page's dialogs: it sat at z-index 1100, above the 1050 of common modal dialogs, so a record opened from a table row had its bottom hidden. It now reads --gl-z-table-panel (default --gl-z-panel, 1040), and its export menu --gl-z-table-panel-overlay (default --gl-z-panel-overlay, 1045).

@geoleaf-plugins/print 1.3.6 - 2026-10-23 ​

Published without a core release: @geoleaf/core stays at 3.18.0.

Security ​

  • The bundled DOMPurify moves to 3.4.17. print ships jsPDF's optional sanitizer in a lazy chunk, loaded on the first PDF export. 1.3.5 carried DOMPurify 3.4.16, covered by three advisories fixed in 3.4.17: GHSA-cg2g-xh64-v4q7, GHSA-mrv2-3pqr-j5jr and GHSA-c6gr-qvv4-5v3f. The plugin's own code is the same as 1.3.5's.

[3.18.0] - 2026-10-20 ​

Fixed ​

  • A queued write no longer follows a profile switch. The write target of a queued capture was resolved from the profile active when the queue drained, and switching profile reloads the page, whose first pass sends what the previous page left. A capture made under one profile thus went to the write target of the other profile's layer of the same id, with that profile's credentials and no signal. Or, when the other profile had no such layer, it was set aside as layerNoLongerWritable. Each capture now carries the profile it was made under, and a pass sends only the active profile's. The others wait, untouched — not sent, not set aside, and spending nothing of their attempts — until their profile is active again. A capture queued by an earlier version carries no profile and is sent as before.
  • A capture is no longer drawn under another profile. The queue keeps every profile's work, and the device store keeps one copy of each entity per layer id, whatever the profile. After a switch to a profile with a layer of the same id, that layer showed the other profile's work: its creations and edits, restored at boot and read from the device when the layer reads offline, and its deletions, replayed on the layer at boot. A layer now draws the active profile's work only. A layer that only the other profile's work fills is read as never downloaded, and loads from the network. One limit remains: an entity the other profile edited or deleted stays off this profile's layer until that edit is sent. A capture queued by an earlier version carries no profile and is drawn under every profile, as before.
  • A download no longer takes another profile's downloaded data. The same device store holds what every profile downloaded, one copy per layer id. Two profiles of the same origin declaring a layer of the same id over two different sources met there: a complete download under one removed what the other had downloaded, the layer then showed the other profile's entities, and a layer only the other profile had downloaded read as downloaded and empty instead of loading from the network. Each downloaded entity now records the source that served it, and so does the download status of each layer: a layer shows, and a download removes, only what its own source served. Two profiles over the same source still share what they downloaded. One limit remains: when both sources serve an entity of the same id, the last download keeps its copy, and the other profile's layer misses that entity until its own next download, which is complete. Data downloaded by an earlier version records no source and is shown under every profile, as before.
  • An archived conflict stays with its profile. Storage.listConflicts() listed, and Storage.clearConflicts() purged, the conflicts of every profile of the origin: a conflict met by a write queued under one profile showed under another whose layer merely shared the id, and a purge there erased it unread. Both now cover the active profile's conflicts only. A conflict archived by an earlier version has no profile and is covered under every profile, as before.
  • Two tabs no longer drain the queue at the same time, where the browser provides the Web Locks API. The same capture could leave twice: the server's answer on local_id kept a duplicate row from being created, but the capture spent its attempts twice.
  • A profile without the PWA no longer takes the service worker from another profile of the same origin. With modules.pwa.enabled off, GeoLeaf unregistered its worker at boot, even when another configuration of the same origin — another page, a configuration passed in memory — enabled the PWA. That one lost its offline mode on its next load without network. A profile that enables the PWA is now recorded in localStorage["gl-pwa-holders"], and the worker is unregistered only once no profile holds it. A profile that turns the PWA off still removes the worker it had. If that storage cannot be read, the worker is unregistered, as before.
  • After a measurement, dragging the map shows grabbing again (@geoleaf-plugins/measure 1.0.13). Leaving a distance, area, circle, rectangle, annotation or custom tool wrote cursor: grab on the map canvas. At rest that looks the same, but it overrode the engine's own rule, and the cursor stayed on grab while the map was dragged. The tools now leave no cursor of their own behind.
  • The editor's strokes follow a palette switch (@geoleaf-plugins/editor 1.6.2). The editor takes its stroke colour from the palette's accent, but it watched for a change in a place no switch touches: once the editor had started, a new palette never reached its strokes. It now listens to geoleaf:palette-changed. Light and dark still leave the stroke colour unchanged, on purpose: the light theme's accent is a pale interface tone, which a stroke on the map would lose.
  • @geoleaf-plugins/routing 1.0.5 and @geoleaf-plugins/table 1.2.1 — test comments only. A routing test said its fixture-capture script was the only code of the repository that talks to a provider; the isochrone plugin's capture script is the second. Four table test comments and titles named their module by a path that does not exist (table/panel.ts for src/panel.ts, table/table-layer.ts for src/table-layer.ts). Both packages ship their sources, hence the versions. No runtime change.

Added ​

  • OutboxEntry.profileId — the profile a queued capture was made under. Optional: an entry queued before 3.18.0 has none, and the database schema does not change.
  • ConflictRecord.profileId, and { allProfiles: true } for Storage.listConflicts() and Storage.clearConflicts() — the profile a conflicting write was queued under, and the option that reads or purges every profile's conflicts, as listPendingEdits does.
  • FeatureRecord.sourceKey — the source a downloaded entity came from (its URL and collection). Optional: a record written before 3.18.0 has none, and the database schema does not change.
  • GeoLeaf.Storage.DB.listPendingEdits({ allProfiles: true }) lists every profile's pending edits, each with its profileId. Without the option it lists the active profile's only, as getSyncCounts() and getSyncStatus() count them. An export of field work passes the option (@geoleaf-plugins/offline-ui 1.7.1 does).
  • profile, an option of GeoLeaf.boot() and GeoLeaf.mount(), decides which profile boots. Until now, a profile the user had picked with the profile switcher — stored in the browser for the whole origin — won over the data.activeProfile of a configuration passed by the integrator, while config was documented as "applied as-is". profile wins over that stored choice. Without it, nothing changes, and the documentation of config now says so.

Removed ​

  • useSharedCluster, from the clustering decision (ClusterStrategyResult) and from a loaded layer's data. It said whether a point layer joined a cluster shared with other layers, but no layer is clustered with another: every clustered layer has its own source, and nothing read the flag. The capability's schema descriptions and the configuration guide no longer describe a shared cluster.

@geoleaf-plugins/isochrone 1.0.2 - 2026-10-20 ​

Published without a core release: @geoleaf/core stays at 3.17.0.

Changed ​

  • @geoleaf-plugins/isochrone is published with a provenance attestation. 1.0.0 and 1.0.1 were published by hand, without one. 1.0.2 goes through the release workflow like the other plugins, and the registry carries its signed SLSA provenance. The code is the same as 1.0.1's.

[3.17.0] - 2026-10-18 ​

Added ​

  • Exclusive mode on the adapter: IMapAdapter.enterExclusiveMode, exitExclusiveMode and isExclusiveMode. A tool that takes over the map's clicks — measuring, drawing, picking a point — enters exclusive mode; until it exits, the core opens no feature popup on click and its hover handlers leave the cursor alone. enterExclusiveMode("crosshair") also paints that cursor on the map canvas and restores the previous one on exit; without an argument the cursor is left as it is. There is no ownership token: tools exclude each other, the last entry wins and any exit releases the mode. Until now the only way in was to set an undocumented flag on the engine map. The three members are optional, so an adapter written outside the library stays valid without them: call them as adapter.enterExclusiveMode?.(…).

Changed ​

  • MapConfig.padding accepts the object form. The type was [number, number]; it is now [number, number] | { top?, right?, bottom?, left? }, the two forms the core reads. An edge the object omits is 0. The profile schema still admits an array of three or four numbers, of which only the first two are read: the type keeps refusing them.

  • UIConfig.showGeocoding is declared, next to showPrint and showMeasure. The three are aliases of modules.<plugin>.showButton, read only when that key is absent — their documentation now says so.

  • @geoleaf-plugins/measure now requires @geoleaf/core 3.17.0 or later (@geoleaf-plugins/measure 1.0.12). ⚠️ The peer range moves from ^3.0.0 to ^3.17.0: a host still on 3.16 gets a peer-resolution refusal. Its tools now enter and exit exclusive mode through the adapter (IMapAdapter.enterExclusiveMode, 3.17.0) instead of setting an undocumented flag on the engine map. Nothing changes on screen: while a tool is armed, a click still opens no feature popup and the tool keeps its own cursor. On an adapter without exclusive mode the tools still work, without it.

  • @geoleaf-plugins/editor now requires @geoleaf/core 3.17.0 or later (@geoleaf-plugins/editor 1.6.1). ⚠️ The peer range moves from ^3.0.0 to ^3.17.0: a host still on 3.16 gets a peer-resolution refusal. An armed editor tool enters exclusive mode through the adapter instead of setting an undocumented flag on the engine map, and the layer picker stands aside for another plugin's tool by asking the adapter (IMapAdapter.isExclusiveMode). Nothing changes on screen. On an adapter without exclusive mode the editor still works, without it.

  • @geoleaf-plugins/isochrone now requires @geoleaf/core 3.17.0 or later (@geoleaf-plugins/isochrone 1.0.1). ⚠️ The peer range moves from ^3.15.0 to ^3.17.0: a host still on 3.15 or 3.16 gets a peer-resolution refusal. Picking a point on the map now holds the crosshair and keeps a click from opening a feature's sheet through the adapter (IMapAdapter.enterExclusiveMode), and the plugin no longer reaches the engine map at all. Leaving the picking now always releases exclusive mode: it no longer hands it back to a tool that held it before, since tools exclude each other. On an adapter without exclusive mode the picking still works, without the crosshair and without that guard.

Fixed ​

  • Three public subpaths of @geoleaf/core/capabilities/* can be imported.capabilities/legend/public-api, capabilities/pwa/public-api and capabilities/filter/engine/index shipped their declarations but no module: since 3.0.0, import { Legend } from "@geoleaf/core/capabilities/legend/public-api.js" type-checked, then threw ERR_MODULE_NOT_FOUND. The three modules are now emitted, with the exports their declarations announce (Legend, PWA, and the ten filter-engine functions). The packaging check now refuses any published declaration that exports a value without its module. The type-only modules under the same subpath (*/types) are unchanged: import type from them works, as before.

  • The documentation of IMapAdapter.addGeoJSONLayer names members the adapter has. It sent the reader to setLayerVisibility(), which the contract does not declare: a layer is shown and hidden by showLayer() / hideLayer(). It also said the data was validated by the adapter; it is handed to the engine's GeoJSON source as it is.

Removed ​

  • IMapAdapter.createClusterGroup, without a deprecation period. ⚠️ It was a REQUIRED member of the adapter contract, so removing it in a minor release breaks semver for TypeScript code that calls it. We accept this on purpose. An adapter written outside the library may keep the method, which nothing in the library calls. No caller is known, and the group it created was registered at rank 100 but drawn on top of the style as it stood, so its rank did not say where it was drawn. To cluster a layer, pass the cluster option to addGeoJSONLayer; that path is unchanged. The internal POI cluster renderer that only this member reached goes with it.

[3.16.3] - 2026-10-17 ​

Fixed ​

  • A layer's attribution option is shown. GeoLeafLayerOptions.attribution was declared by the adapter contract, documented as shown in the attribution control, and read by nothing: a credit passed to addGeoJSONLayer never appeared. It is now set on the layer's source, and the engine's attribution control shows it.
  • A map.padding object naming only some edges frames the map at start-up as a theme view does. The start-up handed the object through as declared, and the adapter reads four edges only when top or bottom is a number: { "left": 320 } started the map with no padding at all, while a theme framing its view kept the 320 pixels. Both now read map.padding through one function — each edge it names applies, an edge it does not name is 0; the array form [horizontal, vertical] is read as before.
  • The profile schema accepts ui.showPrint, ui.showMeasure and ui.showGeocoding. The toolbar has always read them, as fallbacks of modules.print.showButton, modules.measure.showButton and modules.geocoding.showButton read only when the module key is absent — but ui.schema.json refused them, so an editor validating a profile through its $schema called a working profile invalid. They are declared as those aliases, and the profile types generated from the schemas (@geoleaf/core/schemas) carry them. Nothing changes at run time; a new profile should still write the module key.
  • The side panel's theme toggle is not posted a second time into the mobile toolbar. The guard meant to find a toggle already there looked for a variant no button carries, and only the panel's own reference kept the duplicate out. It now looks for the toggle itself — and not for any mobile button: the language switcher and the palette post theirs into the same toolbar.

Removed ​

  • The theme applier's fit on the layers' extent, which could no longer run. It was armed by an internal fitBounds option no caller passed and by a constant set to false, and even re-enabled it would have found nothing to fit — the layer loader records no extent. Themes frame the view they declare (themes[].view, 3.15.0). No public option changes; the profile key performance.fitBoundsOnThemeChange was already refused by the schema.

Security ​

  • Map credits are cleaned before they reach the attribution control. A basemap's attribution (profile) and a layer's attribution option go through one sanitizer: inline formatting (a, span, strong, em, b, i, br) and links whose URL is accepted are kept — a kept link opens in a new tab with rel="noopener noreferrer" —, any other markup becomes text. MapLibre cleans too, but the peer range admits every 6.x, and up to 6.10 its attribution control cleaned by blocklist then re-injected the result through innerHTML. A credit that relied on other markup (an image, a paragraph) now shows as text.

[3.16.2] - 2026-10-16 ​

Fixed ​

  • A basemap that displays is no longer reported as missing something (@geoleaf/core 3.16.2). Loading a profile warned basemaps.<id>: "url" or "style" is required for every basemap carrying neither — yet the basemap registry has always drawn more than that: a basemap made of tiles alone, type: "maplibre", and the four types configured by their own block, image, hillshade, wmts and wms. Those basemaps displayed, and the console said otherwise. The check now asks exactly what the registry accepts, through the same test; it still warns for an entry with nothing to draw, an empty tiles list or an unknown type, and its message names the four ways to be drawable. Unchanged for a host: no API, configuration key or event is added or removed.

  • Asking again for the active basemap cancels a switch still waiting (@geoleaf/core 3.16.2). A basemap asked while the map's style loads waits for it. Basemap A active, B asked in that window, then A asked again: the second request returned on « already active » and left B waiting — and B applied when the style got ready, against the last choice. The active basemap asked again now supersedes any waiting switch, as any other request does. setBaseLayer() says it: the last call wins. Unchanged for a host: no API, configuration key or event is added or removed.

  • A basemap asked while the style updates no longer makes MapLibre throw (@geoleaf/core 3.16.2). A basemap asked while the map's style is not loaded waits for it, and wakes on the engine's styledata, sourcedata or idle. MapLibre 6.7.0 fires one sourcedata from inside its own style update — when layers hidden for a frame are shown again — and the waiting basemap replaced the style right there, under the engine's loop: TypeError: Cannot read properties of undefined (reading 'used'), uncaught, out of the render loop. The gesture was ordinary: a theme that shows layers, then a basemap picked at once. The waiting basemap now applies right after the engine's emission, in a microtask — still before the next frame — and a request that came in between wins. Unchanged for a host: no API, configuration key or event is added or removed.

  • Going back to a vector basemap while another one downloads finishes the switch (@geoleaf/core 3.16.2). Vector basemap A shown, B asked and its style still downloading, A asked again: A's style aborted B's download and, once through, equalled the style the map held — MapLibre 6.7.0 then applies nothing and emits nothing, no style.load. The active basemap read A, but geoleaf:basemap:change was never emitted, and whatever A puts on when it lands was skipped. The switch is now finished when the style has passed through and the live style has the shape it rendered; a style the engine refuses finishes nothing. Unchanged for a host: no API, configuration key or event is added or removed.

[3.16.1] - 2026-10-15 ​

Added ​

  • The zones reachable from a point within a given time: @geoleaf-plugins/isochrone (@geoleaf-plugins/isochrone 1.0.0, first publication). A new MIT plugin, mounted as GeoLeaf.Isochrone and configured under modules.isochrone. It asks a routing engine for the zones reachable from a point — placed on the map, or the user's position read from the core's GPS — by car, on foot or by bike, one zone per duration, and draws them as a stack the user reorders, recolours, hides and removes from a floating window, every action with a named button. The plugin computes nothing itself: the built-in provider asks a Valhalla instance, by default a shared public server with no guarantee, which stops at 60 minutes; an application in service declares its own endpoint, https:// only. A key is never written in a profile: it is given at runtime, GeoLeaf.Isochrone.configure({ endpoint, query }), and kept out of the plugin's own traces. The zones live strictly between modules.isochrone.zIndex and the next rank, among the profile's layers, and move on the map through IMapAdapter.setLayerZIndex (3.15.0). Peers: @geoleaf/core 3.15.0 or later, maplibre-gl 6. The plugin's README lists every key, member and failure reason.

Fixed ​

  • A GPS measurement no longer switches off a GPS the user had switched on (@geoleaf-plugins/measure 1.0.11). The GPS tool read whether the core's geolocation was on from a field the core has never exposed: it always answered no, so the tool always clicked the core's geolocation control — and that click toggles it. Started with the GPS already on, a measurement switched it off, and the measurement then stopped itself, since it follows the GPS being switched off from outside. The tool now reads the core's state through @geoleaf/host-runtime's shared GPS seam: it switches the GPS on only when it is off, and off on leaving only when it switched it on. Unchanged for a host: no API, configuration key or event is added or removed.

  • « Your position » in the routing panel routes from the user's position (@geoleaf-plugins/routing 1.0.4). The button always answered that the position was not known yet, whatever the GPS: the plugin read the core's geolocation state under three names the core has never exposed. It now reads it through @geoleaf/host-runtime's shared GPS seam: it uses the fix the core holds, and when the GPS is off it switches it on — through the core's own control, the only path that asks for the permission — and waits for the first fix, 20 s at most. Three answers stay distinct: geolocation unavailable or turned off by the profile; the permission refused, read through the Permissions API; no fix. A second press while a fix is awaited is ignored. Unchanged for a host: no API, configuration key or event is added or removed.

  • A write no longer makes the engine report the icons it names as missing (@geoleaf/core 3.16.1). Since 3.16.0 a point layer created empty and filled afterwards draws its icons — and MapLibre logged, once per image the write named, Image "<id>" could not be loaded. Please make sure you have added the image before it is needed with map.addImage()…. Nothing was missing on screen: the features reach the source carrying their symbolId before the images are registered, the engine asked in between, and got them a moment later. It showed whenever a write left before the profile's sprite had been registered — a first GeoLeaf.Layers.setData sent as soon as the map is ready, typically — and for GeoLeaf.GeoJSON.updateLayerData, addFeature and mergeFeatures alike. The map now carries a missing-image resolver (map.setMissingStyleImageResolver): asked for one of the profile's icons, it waits for the sprite and registers it, so the engine gets every image in its first answer and has nothing to report. Unchanged for a host: properties.symbolId is written as in 3.16.0, and no geoleaf: event is added or removed. Three things to know. An icon the sprite does not hold is still reported, as before. While the sprite is on its way, the points of that layer are drawn WITH their icons when it lands instead of bare first — bounded to five seconds, after which they are drawn bare and the icons follow as in 3.16.0. And the map holds a single resolver: a host that sets its own on the native map replaces this one.

[3.16.0] - 2026-10-12 ​

Added ​

  • geoleaf:filters:applied says why the pass ran: detail.reason (@geoleaf/core 3.16.0). Since 3.15.0 an active filter judges a layer again each time its store is written (geoleaf:layer:updated), and geoleaf:filters:applied is emitted at the end of that pass as at the end of any other — although the filter itself has not changed. The event carried an empty detail, so a listener could not tell the two apart. It now carries { reason: "filter" | "data" }: "filter" when the filter was applied — Apply, Reset, a control of the panel, GeoLeaf.Filter.applyFilter, applyNow, reset — and "data" when the same filter is replayed because a layer was written. A control touched while a write's pass is waiting makes it a "filter" pass. "filter" does not prove the state differs from the previous pass: Apply pressed twice emits twice. Non-breaking — the event fires exactly when it did, and a listener that ignores the detail is unchanged; the type of the detail in GeoLeafEventMap moves from Record<string, never> to GeoLeafFiltersAppliedDetail. Do not rewrite a layer in answer to "data": a write fires geoleaf:layer:updated, which replays the pass, which emits this event again. A host that relayed every geoleaf:filters:applied to a server search, and wrote the result back with GeoLeaf.GeoJSON.updateLayerData, looped without end from 3.15.0 on — 22 requests every ten seconds, measured. Reload on "filter".

Fixed ​

  • A layer created empty hears a click on whatever it is filled with afterwards (@geoleaf/core 3.16.0). A GeoJSON layer created with no feature is built with the sub-layers of the three geometry families, since nothing says yet what it will hold — and its click and its hover were bound to ONE of them, chosen by precedence: the fill. Filled later with points (GeoLeaf.GeoJSON.updateLayerData, GeoLeaf.Layers.setData), the layer drew them, turned the cursor into a pointer over whatever lay under them, and never heard them: a click on such a point emitted no geoleaf:feature:click for its layer, so the popup that opened was the one of another layer's polygon underneath. The same layer created with a first batch of points worked, which is what made it look intermittent. A layer's gestures are now bound to every sub-layer that draws features — fill, extrusion, line, circle, icon — through one listener for the whole layer. What you may see: a gesture on a feature still emits exactly one event per layer, however many sub-layers render it; on a layer holding several geometries, the feature reported is the one drawn ON TOP — a point lying on a polygon of the same layer answers as the point, where it used to answer as the polygon; a point that draws its icon is clickable over the whole box of the icon, not only over its circle; a polygon is hovered and clicked on the outer half of its outline too; and geoleaf:feature:hover no longer says leave then move when the pointer crosses from a polygon's surface to its outline.
  • A layer created with one geometry draws another one it receives later (@geoleaf/core 3.16.0). A layer's sub-layers were decided once, from its first batch: a layer created with points had nothing to draw a polygon with, so a polygon written to it afterwards was stored, listed in the table, counted by the filter — and never drawn. Whether a layer could hold several geometries therefore depended on what its FIRST batch happened to contain. A write that shows a geometry the layer does not draw — a whole collection (GeoLeaf.GeoJSON.updateLayerData, GeoLeaf.Layers.setData) or features added (addFeature, mergeFeatures) — now gives the layer the sub-layers it lacks: built from the options the layer was created with, inserted at their place in the layer's stack, with its current visibility, zoom range, style and filters, and bound to its click and hover. What you may see: geometries that were silently absent from the map now appear; a layer's sub-layer list (getLayerRegistry().getSubLayerIds(id)) may grow after its creation. A write only ever adds what is missing. Not covered: a vector-tile layer, and a CLUSTERED layer — a clustered source indexes points alone, so lines and polygons written to it are still not drawn.
  • Between two layers under a click, the one drawn on top answers — and it alone (@geoleaf/core 3.16.0). Each interactive layer under the pointer emitted its own geoleaf:feature:click, in the order the layers had been CREATED, and the popup shown was the last one's: a point lying on another layer's polygon opened the polygon's popup whenever the polygon layer happened to be the younger of the two. The core now asks the engine which feature is drawn on top at the clicked point, among every interactive layer, and only that feature's layer emits. What you may see — and it removes events: a click on a place where two interactive layers overlap now emits ONE geoleaf:feature:click, where it emitted one per layer; a listener that relied on the click of the layer UNDERNEATH no longer receives it there. What decides is the drawing order, hence zIndex — a point layer meant to answer over a polygon layer must be drawn above it. A layer that is not interactive (interactiveShape: false) covers nothing, however opaque; neither does a cluster bubble. Hover is unchanged: every layer under the pointer still emits geoleaf:feature:hover, with the zIndex the tooltip ranks by.
  • A point layer created empty draws its icons and its clusters once filled (@geoleaf/core 3.16.0). Two things were decided once, at a layer's creation, from its first batch — so a layer created with no feature and filled afterwards never got them, and a host had to create it with a first feature to work around both. Icons: a layer with showIconsOnMap draws the icon of the points that carry a symbolId, which only the first load injected, and the sprite's images were registered only when the layer was added. A collection written afterwards reached the map bare: discs, no icon — on a layer created empty, and equally on a layer loaded with its icons, which lost them at the first whole-collection write. GeoLeaf.GeoJSON.updateLayerData and GeoLeaf.Layers.setData now give each point the icon its taxonomy resolves, as the first load does, and register the images it names. Clusters: a source is clustered or not for good, and the strategy answered "no" to a collection holding no point — which an empty one is. A layer that ships EMPTY is now clustered when it DECLARES a point geometry (geometry or geometryType: point, multipoint) and the cluster configuration asks for it. What you may see: such a layer now shows icons and bubbles where it showed discs; properties.symbolId is written into the features you pass to updateLayerData on an icon layer, as it already was into those of a first load. Not changed: a layer that ships empty and declares no geometry, or another one, stays unclustered — a clustered source draws points only, so guessing would cost the lines and polygons written to it later; data that shows features is still believed over the declaration.
  • addFeature and mergeFeatures give the features they add their icon (@geoleaf/core 3.16.0). The unit mutations reach the engine as a diff, by another road than a whole-collection write, and that road named no icon either: on a layer with showIconsOnMap, a feature added through GeoLeaf.Layers.addFeature or GeoLeaf.Layers.mergeFeatures drew a bare disc. Each added feature is now given the icon its taxonomy resolves, in the diff sent to the engine and in the store alike, and the images it names are registered. Only the features being added are resolved, never the whole layer. What you may see: properties.symbolId is written into the features you pass to these two methods on an icon layer.

[3.15.0] - 2026-10-12 ​

Added ​

  • The table says when a row is activated: geoleaf:table:rowActivated (@geoleaf/core 3.15.0, @geoleaf-plugins/table 1.2.0). A tenth table event, emitted when the user double-clicks a row or presses Enter while the row has the focus — the table had no event for that gesture, so a host that wanted to open a record from a row had nothing to listen to. The name states an intention, not a gesture, and the table opens nothing itself. The payload is { layerId, featureId, properties }: featureId is the identifier selectionChanged lists in selectedIds (a string, synthetic — __gl_row_<n> — when the feature has none of its own), and properties are the feature's complete properties, as a shallow copy holding no DOM node. Like the nine others it goes out on document and on the map, and is typed in GeoLeafEventMap. The checkbox cell and the sort header never activate. Non-breaking, with three things an integrator can observe: every data row is now a Tab stop (tabindex="0"), with a focus ring; a double-click no longer changes the selection — it used to toggle it twice, which destroyed a multiple selection — and a selectionChanged subscriber sees two emissions around it, the toggle then the restore; and the plugin's peer range on @geoleaf/core moves from ^3.0.0 to ^3.15.0, the first core whose event map declares the name.

  • A basemap's relief can follow the tilt of the map: terrain.activation: "pitch" (@geoleaf/core 3.15.0). A terrain was put on in one way only: by the selection of its basemap, when the block declares default3D — and the camera was then posted to the block's pitch and bearing, and eased back flat when the basemap was left. A map meant to open FLAT and still offer its relief could only be written default3D: true with pitch: 0, which keeps the terrain on at all times: every visitor downloads the elevation tiles, whether they ever tilt the map or not. Measured in a browser on such a basemap, a flat map at a country-wide zoom: ten elevation tiles requested as the basemap opens.

    With "activation": "pitch" the terrain follows the camera. Flat, there is no terrain and no elevation tile is requested — the source is only declared the first time the terrain is put on. Tilted, the terrain is put on once the camera comes to rest at activateFromPitch degrees or more (12 by default), and removed once it comes to rest under deactivateBelowPitch (half of it by default); between the two it stays as it was, so that a camera that stops around one threshold does not flip it at every movement.

    The decision is taken when a movement ends, never during it. While a terrain is on, the engine keeps a copy of the camera for as long as a movement lasts: a terrain removed then put back inside one gesture resumes from that stale copy, and one put on in the middle of an animation is given an elevation prepared by an earlier one. The relief therefore comes a moment after the gesture is released. Whatever tilts the camera counts — a guidance that follows a route included.

    The camera is never moved in this mode: not when the terrain comes, and not when the basemap is left — it is the visitor who tilted the map, and a basemap switch has no business flattening their view. pitch, bearing and default3D: true describe the activation by selection and have no role here: the schema REFUSES them next to "activation": "pitch", as it refuses the two thresholds without it, and the kernel names them in the log when the basemap is registered. A lower threshold that is not under the upper one — which a schema cannot express — is reported at start-up and replaced by its default.

    The terrain survives what the engine does to it: a style rebuilt by the engine resets the terrain without a word, and it is put back as soon as the style is in place; a basemap reached through a style change gets its relief back under a camera that stayed tilted, without the engine having to rebuild the style; two basemaps in this mode hand the relief over with no write when they read the same elevation source at the same exaggeration. A profile that declares none of the new keys behaves as before, the posted camera included. The published schema and the profile types generated from it carry the three keys.

  • A hillshade overlay can follow the relief: hillshade.onlyWithTerrain (@geoleaf/core 3.15.0). A hillshade overlay is always drawn, and reads an elevation source of its own: on a basemap whose relief follows the tilt, a flat map would still request elevation tiles, for the shading. With "onlyWithTerrain": true the overlay is shown only while a terrain is on the map, and hidden the rest of the time — hidden, it requests none. A flat map then stays exactly what it is without either block; tilted, it gets its relief and its shading together. The overlay learns of the terrain from the engine's own terrain event, so it follows a terrain put on by the selection of its basemap just as well. Without the key nothing changes: shading a flat map is a use of its own. A basemap that declares the key without an enabled terrain never shows its overlay, and is told so at start-up.

  • A basemap can carry its relief shading as an overlay: hillshade.overlay (@geoleaf/core 3.15.0). A hillshade block was read on a basemap of type: "hillshade" only, where the shading is the basemap. On any other basemap it was ignored — although the schema accepts it on every entry — and a vector basemap with terrain showed its slopes as flat colours, the ridge line alone readable. With "overlay": true the block is drawn over the basemap, which stays what it is: vector, tiles, WMS or WMTS.

    json
    {
        "basemaps": {
            "plan-relief": {
                "id": "plan-relief",
                "label": "Plan with relief",
                "type": "maplibre",
                "style": "https://tiles.openfreemap.org/styles/liberty",
                "terrain": {
                    "enabled": true,
                    "demUrl": "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
                    "default3D": true,
                    "pitch": 60
                },
                "hillshade": {
                    "overlay": true,
                    "beforeLayer": "waterway_tunnel",
                    "demUrl": "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
                    "exaggeration": 0.45
                }
            }
        }
    }

    On an image basemap the shading sits right above the basemap's layer, under the data layers. On a vector basemap it is inserted before the style layer the new hillshade.beforeLayer names — the first water or road layer, so that it covers the land fills and stays under water, roads and labels. Without that key, or when the style has no such layer (a warning is logged), it goes before the style's first symbol layer: the labels stay readable, water and roads are shaded too. The block's existing settings — colours, exaggeration, direction and anchor of the light — apply as they are.

    The overlay follows the basemap: put back after every style change, removed when the active basemap declares none, left alone when the next basemap asks for the same one. It reads an elevation source of its own, never the terrain's: MapLibre forces a doubled tile size on the source it drapes the terrain over, so a shading read from it is one zoom level coarser. Measured in a browser over a massif, with both on one basemap: 86 elevation tiles requested with two sources, 43 with a shared one. Elevation tiles are not part of the offline preparation.

    Nothing changes for an existing profile: type: "hillshade" behaves as before, and a block without overlay is still ignored on the other types. The tourism demo profile gains a vector basemap, "Plan relief 3D", that shows the three together; its "Satellite" basemap takes the relief when the map is tilted (terrain.activation: "pitch").

  • A layer can change rank in place: IMapAdapter.setLayerZIndex (@geoleaf/core 3.15.0). A layer's rank was given once, when it was added. Nothing in the adapter moved a layer already on the map, so putting one above another meant removing it and adding it again — its source rebuilt, its data re-tiled. adapter.setLayerZIndex?.(id, zIndex) moves a GeoJSON layer to where an insertion at that rank would put it now: under the lowest layer of strictly higher rank, above those already holding the rank. Its sub-layers move together and in order, and the source, its data, the filters, the zoom range, the visibility and the listeners are kept; the order obtained crosses a basemap switch. It answers a boolean, and false means nothing moved: an unknown layer, a rank that is not a finite number, a vector-tile layer, a cluster group, a layer none of whose sub-layers is in the style, or a layer to go under that is not in the style — MapLibre takes a layer out of the drawing order before it notices that one is missing, so the adapter looks first and never asks. The caller then removes the layer and adds it again at the rank it wants. Asking for the rank a layer already holds answers true and moves nothing: the layer is not lifted above the layers sharing that rank. What does not follow: the zIndex a layer declares is not rewritten — the layer manager still orders its rows by it, and the hover tooltip still arbitrates two overlapping layers by it —, a layer's labels do not move with it, and no event is emitted. The member is optional: written adapter.setLayerZIndex?.(id, zIndex) === true, a call on an older core reads as a refusal. @geoleaf/host-runtime names it on the adapter a plugin reaches (HostMapAdapter).

  • GeoLeaf.Isochrone is declared, and modules.isochrone is named in the profile schema (@geoleaf/core 3.15.0). The namespace @geoleaf-plugins/isochrone mounts is an optional member of GeoLeafGlobal, typed by the plugin through GeoLeafPluginApis like every other plugin namespace; and the profile schema names the plugin's block, open, so a profile that declares it validates. The core reads neither: both exist for the plugin, which lives in its own package and is not published yet.

  • A theme can frame the map: themes[].view (@geoleaf/core 3.15.0). A theme said what the map shows, never where: the extent was the profile's — map.bounds, fitted when the map is created and again at the reveal — and switching theme never moved the camera. A theme may now declare view, written like the profile's map block: an extent to fit (bounds, [[south, west], [north, east]]), or a point with its zoom (center as [lat, lng], and zoom). The themes schema admits the key, on primary and secondary themes alike. At start-up, the map opens on the view of the theme the profile starts on, when that theme declares one; otherwise on the profile extent, as before. A shared address that carries a view still wins. On a switch, activating a theme that declares a view moves the camera there, without animation and before its layers load, so that a layer loaded by extent asks for the area the theme frames; a theme that declares none leaves the camera where it is. What does not move: under map.positionFixed the navigation limit stays the profile extent, and the padding stays map.padding; a theme view reaching outside that limit is named in the console at start-up. GeoLeaf.ThemeSelector.setTheme(id, { keepView: true }) activates a theme without touching the camera — which is what the permalink now does for the theme of a shared address, whose own view must hold. A profile that declares no view behaves exactly as before. ⚠️ Themes read from a legacy root-level themes.json, fetched once the map exists, frame on activation only.

  • --gl-font-family: the application's font stack, as a token (@geoleaf/core 3.15.0, offline-ui 1.7.0). The stack was set on the application's root, .gl-page. A node put on document.body sits outside that root and inherited the host page's default instead — a serif, in a document that declares none: measured, the share dialog and every toast were drawn in Times New Roman. The stack is now declared on :root (system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif) and read by .gl-page, the share dialog, the toasts, the two install banners, the filter panel when it mounts on body, and the offline plugin's update banner. A host may override it to give those surfaces its own font; the rest of the interface follows, since .gl-page reads the same token.

  • GeoLeaf.Utils names five more members, and GeoLeaf.Core.getMap() says what it returns (@geoleaf/core 3.15.0). Utils.applyCssText, validateUrl, getDistance, Formatters and DOMSecurity are mounted by the kernel and were reachable as unknown; Core.getMap() and Core.getAdapter() returned unknown and now return IMapAdapter | null. Types only. ⚠️ Utils.validateUrl returns the URL resolved, or null — not a boolean; and Utils.getDistance answers in kilometres.

  • GeoLeaf.Storage.DB names eleven more relays, typed from the engine (@geoleaf/core 3.15.0): getLayer, cacheLayer, removeLayer, getLayersByProfile, getStorageStats, storeImageLocally, getLocalImage, getPendingImages, updateImageUploadStatus, bindLocalImage and cleanUploadedImages. They were reachable through the index signature of Storage.DB, as unknown — so a caller declared its own view of them, and that view was checked against nothing: a status passed as a string where the engine reads an object went unreported once. Types only; nothing changes at run time. getLayer now returns the cached record instead of unknown, and cacheLayer declares the null profile it has always been given by the theme cache.

  • --gl-color-accent-text: the accent as a text colour (@geoleaf/core 3.15.0). The accent is a fill. Written as a text or icon colour on a surface, it fell under WCAG AA in seven theme contexts out of nine — 1.46:1 in the light theme, 2.80:1 with no theme class, 2.86:1 under the green palette. Each theme and each palette now declares the accent's text counterpart, set to reach 4.5:1 on its surface, its muted surface and the accent's own soft veil: #b93b0b by default and in the light theme, #fb923c in the dark theme, #1d4ed8 and #38bdf8 under the blue palette, #166534 and #4ade80 under the green one. The forty-eight declarations that wrote the accent as a text colour read it — the active entry of the mobile toolbar, of the right panel and of the theme selector, links and counters of the feature panel, and their counterparts in editor, measure, navigation, offline-ui, print, routing, connector and field-renderer, which fall back on --gl-color-accent under an older core. --gl-color-accent itself is unchanged: no accent background moves. What you may see: those texts and icons turn a darker orange in the light theme and a lighter one in the dark theme. A host that overrides --gl-color-accent should set --gl-color-accent-text with it, or the text keeps the default orange.

  • GeoLeaf.<Plugin> is typed for whoever installs the plugin (@geoleaf/core 3.15.0 and the fourteen plugins that mount a namespace: cog 1.0.6, connector 1.4.0, editor 1.6.0, file-import 1.0.7, flatgeobuf 1.0.5, geocoding 1.1.4, measure 1.0.10, navigation 1.0.3, position-share 1.0.3, print 1.3.5, realtime-layer 1.0.8, routing 1.0.3, table 1.2.0, websocket 1.0.3). The core declared GeoLeaf.Table, GeoLeaf.Editor and the twelve others as unknown: reachable, never callable from TypeScript without a cast. Each plugin's published declarations now add its API to the GeoLeafPluginApis registry those namespaces read, and export it as a named type (TableApi, EditorApi, CogApi, WsApi…). Importing the package is enough; a namespace whose plugin is not installed stays unknown. A member the plugin does not have is now a compile error, where it was silently unknown. PLUGIN_DEVELOPMENT_GUIDE.md gives the recipe for a plugin written outside this repository.

  • A range filter can offer its upper bound (@geoleaf/core 3.15.0). The filter engine has always applied min ≤ value ≤ max; the panel offered one slider, the lower bound, and an upper bound could only come from a host's GeoLeaf.Filter.applyFilter(). A field of modules.filter.fields now declares it — "bounds": "both" — and gets two sliders that cannot cross, each named for a screen reader in the six languages. One slider stays the default: a panel that declares nothing is unchanged.

  • An entry set aside says why, and its exits are announced (@geoleaf/core 3.15.0). GeoLeaf.Storage.DB.listPendingEdits() now returns quarantine — the motive — and quarantineStatus — the HTTP status of the refusal, when a server answer caused it — on an entry in state: "quarantined"; both are absent from an entry the queue still holds. The motive was only reachable through an internal member, so a host interface could not say why a capture was stuck. And a new event, geoleaf:offline:quarantine-exited, is dispatched once per gesture by requeueQuarantined(), requeueAll() and discardQuarantined(), with exit ("requeued" or "discarded") and the entries that left. A gesture that moves nothing — a refusal, an empty batch — announces nothing.

  • GeoLeaf.Storage.prefetchVerdict(url) — will the offline preparation download this, and if not, why (@geoleaf/core 3.15.0). The origin rule of modules.offline.dataOrigins was only applied during the download, and its refusal was a console warning: an interface offering a basemap could not know beforehand that it would be skipped. The member returns allowed, the origin judged and, on a refusal, a reason — undeclared, notPrefetchable, unparsable, or engineUnavailable when no offline engine is wired. A tile URL template is judged on the concrete tile the preparation would request. And what a download left out is now kept: preparation.refusedOrigins, in the result of cacheProfile() and in the cache manifest, names each refused source with its origin (TilePreparationTrace). That result is typed: GeoLeaf.Storage.CacheManager.cacheProfile(profileId, options?) resolves with a CacheProfileResult, exported with CacheProfileOptions by the sync contract (@geoleaf/core/contracts/sync.contract.js) — the ambient type gave unknown and took no options.

  • GeoLeaf.plugins.registerLazyForAction() is typed, and its descriptor takes what the toolbars read (@geoleaf/core 3.15.0). The member fell into the untyped tail of GeoLeaf.plugins, although the API reference and a registry warning name it as the way to declare a button that must exist before its bundle. Its descriptor refused two fields the reference application declares and both toolbars read: legacyProfileKey, the visibility key read when profileKey is absent, and variant on the desktop slot. Both are typed now, and legacyProfileKey joins the published IModuleUISlot contract, where five plugins already declared it. Nothing changes at run time.

  • The package's front door is typed (@geoleaf/core 3.15.0). import GeoLeaf from "@geoleaf/core" — what every guide teaches — handed over a value typed unknown: the import compiled, its first dereference did not. The default export is now the namespace under its ambient type. ensureGeoLeaf() is exported, for a module that mounts a member on the namespace; it existed and no entry of the package exported it. And the plugin namespaces the core declares (GeoLeaf.Table, GeoLeaf.Editor…) read a new augmentable interface, GeoLeafPluginApis: a plugin's own declarations add its member there, and the namespace is typed for whoever installs that plugin. A namespace nobody registered stays unknown, as before. Nothing changes at run time.

  • map.target and map.id are declared by the profile schema (@geoleaf/core 3.15.0). GeoLeaf.boot() reads both to find the element that hosts the map, and the guides teach them — but the map block is closed, so a profile that declared either one failed validate:profiles. They are now properties of the schema: target is the id of the element, without #, and id is its alias, read when target is absent. Absent both, the boot still looks for geoleaf-map. Nothing changes at run time.

  • A profile may declare a vector-tile layer mixed (@geoleaf/core 3.15.0). A tile source-layer can hold points, lines and polygons at once; declared geometry: "mixed", the layer builds a fill, a line and a circle sub-layer over it, each confined to what it draws. The value was reachable through GeoLeaf.Layers.create() only: both enumerations of the layer schema refused it, so validate:profiles failed on a layer the engine renders. It is now admitted — by geometry and by its alias geometryType — on a layer that names its tiles (data.vectorTiles.tilesUrl), and still refused on any other, where it names no geometry. The API reference now lists the spellings Layers.create() reads beyond the profile vocabulary. Nothing changes at run time.

  • performance.themeBatchSize is declared by the profile schema (@geoleaf/core 3.15.0). The theme applier reads it from the active profile — how many layers of a theme are revealed at a time, 6 when absent — and the profile root, which is closed, did not declare performance: a profile setting the one key the code reads there failed validate:profiles. The block is declared with that key alone, an integer of at least 1. Nothing changes at run time.

  • GeoLeaf.Editor.cancelDrawing() — abandon a shape being traced (@geoleaf-plugins/editor 1.6.0). A shape still in progress — vertices laid, not yet closed — had no public way out: setActiveTool(null) clears the menu's highlight and leaves the drawing engine armed, so the next click added a vertex, and discardDraft() only knows a finished shape. The new member disarms the tool and removes the vertices already laid, as closing the menu does, and returns whether a tool was armed. A finished shape waiting for its form is not touched.

  • A refused sign-in says why (@geoleaf-plugins/connector 1.4.0, typed by @geoleaf/core 3.15.0). The connector's login window read nothing of a refusal but its status: a 403 showed "Authentication failed (403)", and a server's motive — a second factor required, an account locked — never reached the user or the application. A non-2xx answer declared application/problem+json (RFC 9457) is now read: the window shows its detail, or its title, under its own message — as text, cut at 300 characters, in the server's language. And a new event, geoleaf:connector:login-failed, carries status and problem (type, title, detail, and the whole parsed body) to the host, for every sign-in that is refused or cannot conclude; it is distinct from :auth-error, which says an established session ended. Optional for the server: a refusal without that body reads as before. The server contract (§3.2) says so.

  • The offline window acts on the captures set aside (@geoleaf-plugins/offline-ui 1.7.0). Its sync block said how many captures the drain had set aside and offered nothing to do about them — the two exits were reachable from the editor's window only, so an application without the editor showed a red count and no gesture. Under that block, each motive now has its row: how many captures, why, a Retry where the core says a retry can work (GeoLeaf.Storage.requeueableReasons()), and a Discard, confirmed, which says how many captures go. What a retry left aside because its cause is still there is said, not hidden behind the success. Public facade only; the block repaints from what the core announces.

  • « Export the log », from the offline window (@geoleaf-plugins/offline-ui 1.7.0). The journal the core keeps — bounded, redacted — was offered to the user by the boot failure screen alone; once the application had started it took a console to read it. The offline window now ends with a button that downloads GeoLeaf.Log.exportDiagnostic() as a JSON file. No profile key, nothing added to the boot.

  • GeoLeaf.PWA.isUpdateWaiting(), GeoLeaf.PWA.applyUpdate() and geoleaf:sw:update-waiting (@geoleaf/core 3.15.0). The application's updates now wait for the user (see Changed): the event says a new version is installed and waits, the first member reads the same fact at any time, and the second applies it — the new service worker takes over, then the page reloads. geoleaf:sw:update-waiting is typed in GeoLeafEventMap; geoleaf:sw:updated keeps its meaning, a worker activated.

  • « A new version is ready », a banner in the offline plugin (@geoleaf-plugins/offline-ui 1.7.0). It appears when an update waits — at the top of the page, without taking the focus — with Reload, which applies it, and Later, which leaves it waiting until the next visit or the next update. No profile key. An application that does not embed the plugin offers the gesture itself, with the two members above.

Changed ​

  • A focus ring, and a border that is the only sign of a state, are drawn to be seen (@geoleaf/core 3.15.0, offline-ui, routing, table, field-renderer). The accent is a fill: as a ring on the light theme's surface it reaches 1.46:1, where a graphical sign of a state is held to 3:1. Eleven outlines wrote it — the tabs of the right panel, the popup's action, a toast, the language, palette and profile selectors, the share link, the boot failure button, the routing panel's buttons, the gallery's drop target. They read --gl-color-focus-ring, the token each theme already sets and that the base-layer selector, print and offline-ui already read. Nine form controls remove their outline and mark focus by their border alone — the style selects of the layer manager and of the offline window, the filter panel's controls, the table's search and select, the routing address field, the form inputs, tags and image drop zone of field-renderer: that border reads the same token. Three borders that are the only sign of a state outside focus — the active gallery thumbnail, the active GeoJSON badge, the list row under a drag — read --gl-color-accent-text. What you may see: those focus rings and borders turn from a pale orange to the theme's focus blue (#2684ff by default). Accent backgrounds, hover borders and borders that come with a fill do not move. Under the green palette with no theme class, the ring itself went from #16a34a (2.86:1 on that surface) to #15803d. A host that wants its own ring sets --gl-color-focus-ring.

  • The boot frames the profile BEFORE it announces the application ready (@geoleaf/core 3.15.0). At the reveal, the boot emitted geoleaf:map:ready then geoleaf:app:ready, and re-fitted the profile bounds in a 120 ms timer — so a host setting its view at geoleaf:app:ready saw the view it asked for, then saw it jump back. Measured: a zoom of 9.5 set at the announcement was read back at 4.23. The order is now: the engine is given its container's size, the bounds are fitted — unless a permalink carries a view —, and only then the two events leave. Nothing of the boot moves the camera after the announcement. What you may have to change: code that waited some delay after geoleaf:app:ready before touching the camera no longer needs to.

  • A layer's entities are pulled only from a declared origin, in a profile that declares (@geoleaf/core 3.15.0). The pull of offline.source.url applied the origin rule to no source: a profile listing its modules.offline.dataOrigins still pulled from an origin it had not listed. From the first declaration on, a source is pulled when its origin is declared or is the page's own; otherwise pullLayer() sends nothing and reports refused: "originUndeclared", and the offline preparation records it with the layer. Neither cacheable nor prefetch is read — the layer naming its source is the request — so an authenticated origin is pulled like any other. A profile that declares no origin is unchanged. ⚠️ A profile that declares origins and pulls a layer from a third-party origin it left out must add that origin to dataOrigins.

  • The same rule now reaches what a profile names one by one, and the pages a source renders (@geoleaf/core 3.15.0). Three resources of the offline preparation took their URL as written, third parties included: a layer's direct url, a field's fetchOptions list and the icon sprite (icons.spriteUrl). And a pull judged offline.source.url once — the pages after the first were asked at the URL the server rendered in its next link, or at the cursor cursorPath names, whatever its origin. In a profile that declares modules.offline.dataOrigins, all of them now obey the entities' rule: a declared origin or the page's own, nothing else. A resource left out is named in the log and in preparation.refusedOrigins. A pull whose next page names an undeclared origin stops there and reports cursorRefused: true, a new member of pullLayer()'s report: the run is partial, removes nothing from the device and leaves no delta mark. streamOgcApiFeatures() takes the judge as an optional sixth argument, followCursor, and reports a refusal in cursorRefused; the online display passes none and is unchanged. A profile that declares no origin is unchanged. ⚠️ A profile that declares origins and takes a sprite, an option list or a GeoJSON file from a third party it left out must add that origin to dataOrigins.

  • A capture that needs no session no longer waits behind one that does (@geoleaf/core 3.15.0). The drain stopped at the first capture of a write.auth: "bearer" layer met with no session open, and everything queued behind it waited too — a capture of a "none" layer included. The pass now walks past the held captures and sends the rest. From the first one held, every bearer capture of the pass waits, even if the session opens meanwhile: the writes of one entity keep their order.

  • The offline preparation presents what the display presents — vector tiles and glyphs carry the connector's token (@geoleaf-plugins/connector 1.4.0). The connector's fetch patch left .mvt and .pbf URLs out, "because the MapLibre bridge handles them". It does, for the map, whose tiles load in a worker the patch never sees; the exclusion only ever applied to the offline preparation, which downloads tiles and glyphs on the main thread — an authenticated vector source was prepared without its token, and refused. Every request under baseUrl now carries the token, whatever its format. The server contract also says what was true and unwritten: the preparation sets no credentials mode, so a request to the page's own origin carries that origin's cookies, and no other request does.

  • The defaults of geocoding and of the export keys of table are written once, in each plugin's table (@geoleaf-plugins/geocoding 1.1.4, @geoleaf-plugins/table 1.2.0). provider, debounceMs, minChars, resultLimit, position and flyToZoom on one side, exportFormats, csvSeparator and csvIncludeGeometry on the other, were applied where the value was read — resultLimit and flyToZoom at two places each. Nothing changes for a profile: the values are the same, and a key written null still keeps its default.

  • The server recipes shipped with a deployable folder pin by folder, not by extension (nginx.conf.example, .htaccess and the server contract page of deploy-core and deploy-full). Both recipes sent max-age=31536000, immutable to every .mjs, .js, .css, .png and .svg, wherever the file lived. Only dist/ earns it: each of its files is named after its content or requested with its token. Three families kept a stable name under that rule — the map engine (vendor/), the application's icons (icons/) and the sprite sheet of each profile — so a returning visitor kept the old engine for up to a year after an upgrade, and a sprite you had just rewritten did not reach them, while curl returned the new file. The recipes now send no-cache for vendor/ (revalidated at each visit, a 304 when unchanged), a day for icons/, and an hour for everything under profiles/ but its root file — what the table of the server contract page already promised for profile data, and what neither recipe delivered for a .svg, a .qml or a .fgb. The Apache recipe now matches on the request line (<If "%{THE_REQUEST} …">, Apache 2.4): it rewrites a request to its .gz twin, and a <FilesMatch> judges the file served. It also types its precompressed files with ForceType: measured on an Apache 2.4, the former AddType text/javascript .mjs.gz .js.gz typed nothing, and every .gz twin left as application/x-gzip — a type a browser refuses for a module script. What to do: if your server configuration was copied from an earlier recipe, replace its per-extension cache block with the folder blocks of the new one. Visitors who already received a pinned file keep it until it expires — nothing sent now can shorten a header already stored.

  • What a deployable folder pins for a year is requested by its content (deploy-core, deploy-full, and their nginx.conf.example). Two defects of the folder's cache, fixed together. The nginx recipe did not deliver the no-cache its own table promises to init.js and sw-core.js: its block by extension — a year, immutable — was written above the block that names them, and nginx stops at the first regular expression that matches. The entry points now come first, and the recipe says that block must stay there. If you copied the earlier recipe, move the block of the entry points to the top: a browser that cached init.js for a year does not see a new version. The Apache recipe was right — the last block wins there. And three files were pinned for a year under a name that did not change with their content: the GeoJSON worker, the stylesheet, and the plugins init.js loads on demand. Each is now requested with a token computed from its bytes, and the worker joins the service worker's pre-cache.

  • A new version no longer takes over a page that is in use (@geoleaf/core 3.15.0). The service worker skipped the wait at every install: a deployment replaced the worker under whoever had the application open, and its activation purged the caches of the version that page was running — the lazy chunks it had not loaded yet included, offline or not. It now skips the wait at a first install only. At an update the new worker installs, then waits until GeoLeaf.PWA.applyUpdate() is called or every tab of the application is closed — the browser's own rule. What changes for a deployment: users on @geoleaf-plugins/offline-ui are asked, by a banner; an application without it receives a new version at its next cold start instead of mid-session, unless it offers the gesture (PWA — When a new version is deployed). A page already open on the previous worker when this version is deployed shows no banner — its code predates it — and gets the new version once closed and reopened.

  • A permalink writes the filter one parameter per field (@geoleaf/core 3.15.0). The filter was ranged by kind, in four slots — gl_filter, gl_cats, gl_tags, gl_rating: two fields of the same kind shared a slot and were restored with the same value, a range kept its lower bound only, and a taxonomy field's sub-categories lost the category they were checked under. Each constrained field now has its own parameter, gl_f.<id>, named by its id in modules.filter.fields — 100..500 for a range, a,b,c for a list, the sub-categories under gl_f.<id>.sub — and GeoLeaf.Permalink.getState() exposes them as fieldFilters. Links already shared keep working: the four slots are still read, and restored as they were. The application no longer writes them, so the filter of a link copied from 3.15.0 is not read by an earlier version — its view still is. permalink.fields gates as before, its four filter names now reading « the fields of that kind ». boolean and proximity fields are still not carried.

  • write.auth is read (@geoleaf/core 3.15.0, @geoleaf-plugins/connector 1.4.0). The key was declarative: the schema and the published type accepted bearer and none, and no code read either — a layer could say its endpoint takes no token and still have the session's token attached, or say it requires one and have its captures sent without. Both values now have an effect. none: every request of the layer's writes carries the declaration (the geoleafWriteAuth member of its init), and the connector attaches no token to it, even under its baseUrl. bearer: while the session reader (GeoLeaf.Sync.registerSessionReader) says no session is open, the capture is not sent — it stays queued, untouched, and the pass counts it in a new member, heldForSession, of pushOutbox()'s report and of geoleaf:offline:outbox-drained. The pass does not stop there: a capture of a layer that needs no session, queued behind it, leaves. Unlike an authRequired halt, a held capture does not pause the automatic triggers: it leaves on its own once the session is back. A layer that declares nothing behaves as before, and so does a bearer layer on a page where nobody registered a session reader.

  • geoleaf:layer:updated is announced by every writer of a whole collection (@geoleaf/core 3.15.0). The event left from the calls of GeoLeaf.Layers alone. A real-time layer's ticks, an OGC layer's auto-refresh and a host's own GeoLeaf.GeoJSON.updateLayerData() rewrite the same store around it, unannounced: an open table kept its rows, and an active filter did not re-judge what arrived — a newcomer that passed stayed hidden — until the user's next gesture. They are announced now, once per write, where they all converge; the calls of GeoLeaf.Layers still announce once each. An OGC auto-refresh also resets the layer's incremental-update verdict, which it left stale. A plugin that feeds its source without writing the store is still not announced.

  • A requeue out of quarantine asks for a pass (@geoleaf/core 3.15.0). requeueQuarantined() and requeueAll() used to put entries back in the queue and stop there: on a calm queue nothing left before the next trigger, and the documentation said to call pushOutbox() afterwards. The entries are now sent at once, a dead session included — requeueing authRequired is saying the session is back. A pushOutbox() left after a requeue is harmless: the two passes are serialised.

  • A declared origin is judged on its declaration by the preparation of basemaps and tiled layers — the page's own included (@geoleaf/core 3.15.0). The preparation of basemaps and tiled layers admitted the page's own origin before reading modules.offline.dataOrigins: an own origin declared authenticated, or cacheable without prefetch, was downloaded all the same. The declarations are now read first. Left undeclared, the page's own origin is still prepared, so a portable profile changes nothing. Declared, it is prepared only with cacheable: true and prefetch: true — and since roles are not consulted there, declaring it for a same-origin data API also decides for the tiles it serves. The contract said both things in two places (DataOriginDeclaration); it now says this one.

  • Storage.DB relays are checked against the module they forward to (@geoleaf/core 3.15.0). Every sub-module of the IndexedDB engine was typed with one index signature that accepted any argument for any method, so a relay could drift from its module without an error — that is how a string once reached a parameter read as an object. The relays are now typed from the module registry. Two of them tighten as a result: Storage.DB.cacheLayer() takes a string, an ArrayBuffer or an object, and Storage.DB.storeImageLocally() takes the image record — both were unknown. Nothing changes at run time for a JavaScript caller.

  • The style loader no longer judges label keys the renderer does not read (@geoleaf/core 3.15.0). Three checks ran at load on keys with no effect: label.font.weight had to be an integer from 0 to 100, label.buffer.opacity a number from 0 to 1, and label.background an object. A failed check refused the whole style — so a style written on the CSS scale ("weight": 700), for a key that changed nothing, did not load. The three checks are gone; the keys are ignored. font.sizePt, color, opacity, buffer.color, buffer.sizePx and offset.* are judged as before. In the types, background leaves LabelUserConfig and the resolved label style, and the font and buffer sub-objects name only what is read; they keep their index signature, so an object that still carries the old keys compiles.

  • BREAKING — the connector's HTTPS rule judges where the credential goes, and covers the login endpoint (@geoleaf-plugins/connector 1.4.0). http:// was tolerated whenever the PAGE was served from a development host, so a page on localhost could send its token in cleartext to any host, remote ones included; and auth.endpoint, which receives the password, was only checked for being non-empty. The rule now reads the URL itself: baseUrl, auth.endpoint, auth.signupUrl and auth.forgotPasswordUrl must be https://, or http:// toward a development host — localhost, *.localhost, the loopback range, a name under .test. What breaks: a local page whose connector targets a LAN address or a remote host over http:// no longer starts — configure() throws a ConfigError. To migrate, serve that backend over HTTPS, or reach it through a name under .test. What loosens: a URL toward a development host is tolerated whatever page asks. A relative URL targets the page's own host, as before.

  • The editor carries @geoleaf/host-runtime once (@geoleaf-plugins/editor 1.6.0). Its bundle held two copies of the dialog and modal-shell modules: its own, and the one inlined in the built @geoleaf/field-renderer it bundles. The form library is now bundled from its sources, so both sides resolve to the same modules — the entry chunk loses 5.4 KB (0.9 KB gzipped), and a stylesheet adopted "once per key" is adopted once. Nothing changes in the editor's API, nor for an integrator using @geoleaf/field-renderer on its own.

Removed ​

  • A deliverable no longer carries a second copy of each profile sprite, nor the source logo (deployable application). The build flattened every profile's sprite into icons/, next to the copy under profiles/<profile>/icons/ — byte-identical, compressed twice, and referenced by nothing: a profile's taxonomy names the copy inside the profile (icons.spriteUrl), and that is the one the core fetches. icons/logo.png, the source the PWA icons are generated from, was shipped and asked for by no page, manifest or worker. ⚠️ A host page that reads icons/sprite_<profile>.svg at the root of a deliverable must read profiles/<profile>/icons/sprite_<profile>.svg.

  • The Profiles Guide and the Configuration Guide are gone (@geoleaf/core 3.15.0). Both taught the layout of a profile from before its files were reorganised, and were frozen with thirty examples that the schemas refuse. Three pages replace them, each with something that keeps it true: the Quickstart Tutorial, replayed from its own blocks; the Profile JSON Reference, a reading guide; and the parameter reference generated from the schemas. Links to the two pages are rewritten across the documentation; /PROFILES_GUIDE and /CONFIGURATION_GUIDE on the documentation site no longer resolve.

  • BREAKING (types only) — GeoLeaf.Storage no longer accepts any name as a member (@geoleaf/core 3.15.0). The ambient type of GeoLeaf.Storage ended with an index signature, [key: string]: unknown: any name was a member, typed unknown. A misspelled call — pushOutBox() for pushOutbox() — compiled down to a diagnostic that says nothing about the name, and no gate could tell a real member from an invented one. Every member of the façade is now named, typed from the façade itself, and the index signature is gone: a name the façade does not carry is a compile error. What breaks: TypeScript code reading an undeclared member of GeoLeaf.Storage through the ambient type — an underscore-prefixed internal, or a property an application set on the façade itself. Nothing changes at run time, and a JavaScript caller is unaffected. GeoLeaf.Storage.DB keeps its own index signature.

    ⚠️ Versioning exception. This removal was not announced in an earlier published version, as the versioning policy asks. An index signature is not a symbol an editor can strike through, and no consumer follows a semver range today. The exception is recorded here and in the policy; it is not a precedent.

  • BREAKING (profile validation only) — six label keys that nothing rendered leave the schemas (@geoleaf/core 3.15.0). font.family, font.weight, font.bold, font.italic, buffer.opacity and buffer.noFill were accepted in a style file's label object and in a layer's labels block, and no renderer read them: the font stack of a label comes from the map style the engine has loaded, and a text halo has a colour and a width, nothing else. A profile could set bold: true and wait for a bolder text that never came. Both schemas now declare only what is drawn — font.sizePt, color, opacity, buffer.enabled, buffer.color, buffer.sizePx, offset.*. What breaks: the published schemas (@geoleaf/core/schemas/*.schema.json) and the types generated from them refuse a style or a layer that still declares one of the six — so does a validator plugged on them, and so does a typed literal that carries one. What does not: at run time such a key is still ignored, and the rendering of a style that carried it is unchanged. To migrate, delete the six keys.

    ⚠️ Versioning exception. This removal was not announced in an earlier published version, as the versioning policy asks. A JSON key has no @deprecated tag to carry, the labels reference already said of each of them that it was not rendered, and no consumer follows a semver range today. The exception is recorded here and in the policy; it is not a precedent.

Fixed ​

  • map.initialMaxZoom caps the initial framing (@geoleaf/core 3.15.0). The key was documented as the ceiling that keeps a small extent from opening at street level, and it capped nothing: it only gave the zoom the map is created at, which the framing on map.bounds replaced at once. A profile framing a single district started as close as the extent allowed, whatever the key said. A map that starts on an extent — map.bounds, or the view.bounds of the theme the profile starts on — now never frames closer than this zoom, at creation and again when the application is revealed. What you may see: a profile that declares the key on an extent smaller than that zoom shows now starts further out, at the zoom it asked for. A profile that does not declare it is unchanged — only a declared key caps — and so is one whose extent is larger than the ceiling. The ceiling does not apply to the user's zoom (map.maxZoom), nor to a later theme switch: a theme that must open at a given zoom declares view.center and view.zoom. The adapter contract gains the option that carries it: IMapAdapter.fitBounds(bounds, { maxZoom }).

  • Scrolling a large table no longer keeps every row it scrolled past (@geoleaf-plugins/table 1.2.0). Past the virtual threshold the table re-creates its window of rows on every scroll event, and each row came with a click listener and a checkbox listener whose teardowns were only run at the next render — so every row ever scrolled past stayed in memory, detached, until the layer, the sort or the search changed. Measured in Chromium on a 30 000-row layer, after 2 000 scroll events: 98 122 rows alive for 52 in the document, and a heap grown from 31 to 58 MB. Row selection now goes through two listeners on the table body, like row activation: nothing is attached while scrolling, and a row that leaves the window is released. No behaviour changes.

  • A second relief basemap no longer reads the elevation of the first (@geoleaf/core 3.15.0). A terrain put on by the selection of its basemap (terrain.default3D) declared its terrain-dem source only when the id was free. The source of the basemap just left is still there at that moment — its removal waits for the next frame, and is then given up because the incoming basemap claimed it: a second basemap with another terrain.demUrl draped its relief over the elevation of the first, with nothing in the log. The source in place is now compared with the one the basemap asks for — kept when it is the very same, replaced otherwise. When a layer still reads a source of another elevation, the engine refuses to let it go: the terrain is not put on, the camera is not posted, and a warning says so. A removal the engine refuses when a basemap is left is logged as such; it used to be logged as done.

  • A relief put on by the selection of its basemap comes back on a style the engine rebuilt (@geoleaf/core 3.15.0). The engine drops the terrain of a map whose style is replaced by one that does not declare it — measured in a browser: the terrain and its source gone, the basemap still the active one. Nothing put it back before the next basemap switch, and isTerrainActive() went on answering true. The terrain now listens to style.load and puts its source and itself back, the camera left where it is; a style replayed as it was, or a restored WebGL context, keeps its terrain and is not written again. isTerrainActive() and getActiveTerrainBasemapKey() read the map instead of a flag.

  • refreshBasemap no longer posts the camera of a relief basemap again (@geoleaf/core 3.15.0). Re-applying the active basemap went through its whole activation, jumpTo included. Measured in a browser, a camera the visitor had turned to 20° and 90° came back to the profile's 60° and 0° when the basemap was refreshed — which the editor does as it starts, when the basemap's source is missing. A basemap applied again on the same map has its source and its terrain checked, written only if they went missing; the camera is posted by the SELECTION of a basemap, and by nothing else.

  • Unmounting an application no longer leaves its relief behind (@geoleaf/core 3.15.0). The basemap registry forgets its map when an application is unmounted; the terrain manager kept a state of its own — a relief is on, and for which basemap — that nothing reset. Unmounted while a basemap with terrain.default3D was active, the application left that state standing, and the next map inherited a relief it never had: the first basemap applied to it "left" that relief, and its camera was eased to pitch: 0, bearing: 0 — a view the application had just opened, flattened and un-rotated, with nothing logged. The state now goes with the map, and nothing is written on the map that is being torn down. Reachable only through GeoLeaf.mount() and its teardown.

  • Switching between two vector basemaps that share a style completes (@geoleaf/core 3.15.0). Two basemaps declaring the same style and the same credit — "Plan" and "Plan + relief", the second carrying a terrain block — are the natural way to offer a map with and without relief. The switch asked the engine for the style again; the diff between a style and itself holds no operation, and MapLibre then fires no style.load, which is what finishes a style change here. Measured in a browser: the selector showed the second basemap, the map kept no relief, and geoleaf:basemap:change was not emitted. Some styles hid it: the filters patched on OpenFreeMap's "liberty" keep its diff from ever being empty. Such a switch now keeps the style the map holds and applies only what differs between the two basemaps — it is immediate, and the style is not downloaded again. No style.load occurs on it: the event to listen to for a basemap change is geoleaf:basemap:change.

  • refreshBasemap no longer applies a WMTS basemap without its tiles (@geoleaf/core 3.15.0). A WMTS basemap has no tile URL of its own: activating it resolves one from the service's GetCapabilities. GeoLeaf.Baselayers.refreshBasemap() — which a plugin calls when it finds the basemap source gone — re-applied the registered definition as it stood, with no tile. Called while the resolution was in flight, it took the slot the resolution was about to fill, whose own source was then refused on the identifier: the basemap stayed empty. Called after it, it put back a source that requests nothing. It now leaves a resolution in flight alone, and resolves the service again when the source is gone — so a WMTS basemap comes back asynchronously, where the other raster types come back before the call returns. A rejected resolution no longer reads as one still in flight.

  • A profile basemap of type image, wms or hillshade keeps its configuration (@geoleaf/core 3.15.0). The application rebuilds each basemap of a profile key by key before registering it, and it handed over the terrain and wmts blocks only: imageSource, wms and hillshade never reached the registry. A basemap of one of these three types declared in basemaps.json was therefore registered without the block its type requires, while the same definition passed to GeoLeaf.Baselayers.registerBaseLayer kept it. The five blocks now travel as they are; a block the basemap does not declare still sets no key.

  • A vector-tile layer takes its rank among the other layers (@geoleaf/core 3.15.0). It was inserted right under the point layers whatever its zIndex, while still being recorded under that rank. Measured in a browser, with layers of rank 10 and 50, then a tile layer of rank 30, then layers of rank 20 and 40: the style read 10, 40, 50, 20, tiles — the tile layer above everything, and the rank-20 layer, anchored on it, above the rank-50 one. It now reads 10, 20, tiles, 40, 50. ⚠️ This changes what a map with tile layers shows: a tile layer no longer sits on top of every GeoJSON layer, and one that declares no zIndex has rank 0 — under all the others. Give it the rank it should have.

  • Four more surfaces are drawn in the application's font (@geoleaf/core 3.15.0, print, connector). Each was opened in a browser and read. The buttons of the two install banners were drawn in Arial, next to a banner in the application's font: a button does not inherit its font. The hint of the print extent selector took the map engine's font, the selector living inside the map container. The close button of the print window, and the fields and buttons of the sign-in window, were in the browser's own. All read the application's font now.

  • Both server recipes type a layer's data (deployable application). Served by Apache behind the shipped .htaccess, a .geojson left with no Content-Type while its precompressed twin left as application/json — two answers for one file, depending on what the client accepts; nginx sent it as application/octet-stream. Measured by serving a deliverable behind each recipe in a real server. Both recipes now declare application/json for the extension. An integrator who copied a recipe before this version adds one line: AddType application/json .geojson for Apache, application/json geojson; in the types block for nginx.

  • The table panel is drawn in the application's font (table). The panel sits on document.body, outside the application's root, and set no font: measured, its buttons, its search field and its selects were drawn in Arial, and its text in the host page's default — a serif, in a document that declares none. It reads --gl-font-family, and its form controls inherit it.

  • Font stacks written by hand read --gl-font-family (@geoleaf/core 3.15.0, print, connector, and the modal shell the plugins share). The layer manager, the base-layer selector, the coordinates display, the popup, the lightbox and the branding block each wrote a stack of their own — two of them with Roboto and Helvetica Neue — and so did the print window, the login window and the shared modal shell: a host overriding the token changed the rest of the interface and not these. The offline badge reads it too.

  • On a phone, what the network state shows stays in view (@geoleaf/core 3.15.0, offline-ui 1.7.0). Three surfaces, measured at 375 and 390 px. The offline badge the network detector puts on the map sat on the row the theme selector takes below 768 px, half hidden for as long as the network was down: it now sits below that row. The sync strip scrolls sideways without a visible scrollbar and carried its action and its close button off-screen — the action ended at 444 px of a 375 px screen; the status row of the offline window did the same in each of the six languages. Both keep their buttons pinned to the end of the row, the facts scrolling under them. A disabled action is dimmed by mixing its colours with the row's ground instead of opacity, so nothing shows through it.

  • The review form's submit button wears a variant the core defines (field-renderer). It wrote gl-btn--primary, a class only the offline plugin's sheet defines: without that plugin the button had no fill. It writes gl-btn--accent, which the core's button system carries.

  • The popup's link to the side panel is labelled in the interface language (@geoleaf/core 3.15.0). "Voir plus" was written in the code and served to the six languages. It is now the catalogue key feature-info.popup.more.

  • The age of the last synchronisation names the day in the user's language (@geoleaf/core 3.15.0, offline-ui 1.7.0). Past twenty-four hours, the sync strip and the status block of the offline window wrote the count followed by j, in the code: an English interface read "synced 4 j". The unit now comes from the catalogue, key ui.sync.unit_day — j in French, d in English, Spanish and Portuguese, g in Italian, Tg. in German. min and h stay as they are: they are the unit symbols, shared by the six languages. Under a core that does not carry the key yet, the plugin keeps the unit it wrote before.

  • A FlatGeobuf layer stops refreshing when it leaves the map, and draws the latest answer only (flatgeobuf 1.0.5). A layer loaded with autoRefresh kept its moveend listener for the life of the page: after removeLayer, every map move still fetched the file for a source that no longer existed. The plugin now asks the adapter before each refresh and removes its listener once the layer is gone; a refresh still waiting on the debounce is dropped with it. And two refreshes answering out of order could leave the older extent on the map: only the answer to the latest request is drawn.

  • A printed map asks for a drawing buffer it can read back (print 1.3.5). The offscreen map of the print flow passed preserveDrawingBuffer: true at the top level of its options. MapLibre 6 reads the context attributes from canvasContextAttributes only, so the key was ignored and the context was created with the default, false — measured in Chromium: asked false, got false. The capture held because the canvas happened to be read before the browser cleared it. The option is now passed where MapLibre reads it.

  • An update of the service worker whose pre-cache fails no longer installs (@geoleaf/core 3.15.0). The install handler logged the failure and went on. For a first install that is intended. For an update, a worker with an empty static cache then waited, was announced and was offered by the update banner: accepting it replaced a working worker by one that had cached nothing, whose activation purged the caches of the one it replaced. When a worker already holds the scope, the failure is now rethrown: the browser discards the new worker, the one in place keeps serving, nothing is offered, and the next update check tries again. The page logs a warning when a worker is discarded before it finished installing. ⚠️ A file missing for good from the pre-cache list therefore holds every update back until a deployment restores it.

  • A capture discarded from the quarantine leaves the map, or loses its badge, at once (@geoleaf/core 3.15.0). GeoLeaf.Storage.discardQuarantined() returned the local record to the server's truth and did not touch the layer: an abandoned creation stayed drawn, and an abandoned edit kept the "pending" badge of a capture that would never leave, until the page was reloaded. The entity now leaves its layer when the server has nothing to give back, and loses its badge otherwise. An abandoned edit keeps its content on screen until the next pull that carries the entity: the device holds no other version to draw. ⚠️ Reloading the page is not enough on a layer that reads the device before the network — measured, the entity is still drawn with the abandoned geometry after a reload.

  • mapOptions.preserveDrawingBuffer declared by a profile reaches the engine (@geoleaf/core 3.15.0). The key — the only one of config/core/features.json — was validated and read by nothing at boot: measured on a profile declaring true, the WebGL context was created with false. Two things were wrong. The boot built the engine's options from the map block alone and never read the key; and the adapter passed the option at the top level of the engine's constructor, where MapLibre 6 ignores it — it reads canvasContextAttributes.preserveDrawingBuffer — so the print plugin's own automatic activation did nothing either. Both are fixed. The list stays closed: no other key of that block reaches the engine. What you may see: a profile that declares true now gets a context that keeps its buffer, which a canvas capture needs and which costs some rendering performance — and so does, without declaring anything, an application whose print plugin is registered before the map is created.

  • The automatic refresh of an OGC layer prepares its data like the first load (@geoleaf/core 3.15.0). On a map move, a layer declaring autoRefresh handed the source what the network returned: no data.mapping, no conversion, no symbol ids. A layer drawing icons lost them at the first move.

  • A layer's declared geometry is read the same way by the legend and by the map (@geoleaf/core 3.15.0). The legend knew line, polyline and polygon and drew every other value as a point — multipolygon, multiline and fill-extrusion included. And a layer declaring geometry alone, served empty, got no sub-layer for the features it would receive later: only geometryType was read when the layer was created. Both read the kernel's family table now. mixed keeps the point symbol in the legend.

  • Under writes closer together than their debounce, the table and an active filter follow (@geoleaf/core 3.15.0, table 1.2.0). Both waited for a quiet moment after geoleaf:layer:updated — 150 ms and 300 ms — and each write pushed it back: a source writing faster left them on the state from before the burst for as long as it lasted. The wait is now bounded, 500 ms for the table and 1 s for the filter. Typing in the filter panel is unchanged.

  • A photo delivered while its form is open, or its entity selected, keeps its address (editor 1.6.0). Two paths gave an entity back the token of a photo already on the server. The return of the network uploaded the photos of a form not yet saved; bound to no entity, they were purged once delivered, and the save wrote tokens designating nothing. Such a photo now waits for its form to be saved. And a move validated on an entity selected before its photo landed was built from the attributes as they were at the selection; they are now read from the layer when the move is validated.

  • The offline window speaks the application's language (offline-ui 1.7.0). The cache panel and the export panel wrote some forty labels as literals, most of them in English, four in French. They go through the plugin's catalogue, in the six languages.

  • On a phone, the theme pills no longer cover the sync strip (@geoleaf/core 3.15.0). The strip publishes its height in --gl-map-top-inset, and every surface anchored at the top adds it to its own top — except the two rules that place the theme pills under 768 px, which restated top without it. With the strip shown, the pills painted over the count of writes owed. Both rules now add the token; nothing moves while the strip is hidden.

  • A capture waiting for a session says so, and is not forgotten by the periodic retry (@geoleaf/core 3.15.0). A layer declaring write.auth: "bearer" holds its captures while no session is open. Two things were missing. The sync strip counted the capture among the writes owed and said nothing else: online, one pending, a "Synchronise" button that changed nothing. It now reads "sign-in required" for as long as the last pass held a capture (heldForSession > 0) and something is owed, in the six languages; learning it brings back a strip that was dismissed. It offers no sign-in button — who holds the session is not the core's to know. The periodic tick read such a pass as "nothing to replay", because a pass that holds everything makes no request and so reports attempted: 0: the tick went to sleep on a queue that was not empty, and the capture then waited for a network change, a return to the tab or a new write. The tick now stays lit under that halt, as the contract said.

  • A text written on the theme accent is readable in every theme and palette (@geoleaf/core 3.15.0, offline-ui 1.7.0, print 1.3.5, geocoding 1.1.4, measure 1.0.10, table 1.2.0, editor 1.6.0). Fourteen controls painted their background with --gl-color-accent and wrote on it a near-white — --gl-color-text-inverse, --gl-color-bg-surface or a literal #fff. The light theme's accent is pale: the primary button of the offline window measured 1.39:1, where WCAG AA asks for 4.5:1. Each now reads --gl-color-accent-contrast, the colour a theme declares readable on its own accent: the offline window (header, primary button, sync and re-queue actions), the print footprint's confirm button, the search and proximity submit buttons, the GPS dialog's primary button, the active tab of the right panel and of the table, the editor's queue badge, the sync strip's action and the share dialog's copy button. Three theme blocks were missing the tokens that make the pairing true: the dark theme now declares its own --gl-color-accent-hover (#9a4a07 — the root hover left white text at 2.1:1), the dark blocks of the blue and green palettes redeclare --gl-color-accent-contrast and --gl-color-accent-hover (they inherited the base dark theme's white text on a light accent, 2.1:1 and 1.7:1), and the green palette's hover becomes #22c55e (4.0:1 before). In the offline window, three other texts fell under the threshold and are realigned: the active tab no longer uses the accent as a text colour, the cache state reads the theme's success and danger colours, and two columns lose a grey written inline. Its layer boxes and style selects now carry an accessible name. What you may see: on those controls, the text turns dark in the light theme, and the hover of an accent button darkens instead of lightening in the dark theme. A host that overrides --gl-color-accent should set --gl-color-accent-contrast with it.

  • Sixteen declarations of the core's stylesheets read a colour token no theme defines (@geoleaf/core 3.15.0). An undefined custom property resolves to its hard-coded fallback — the same value in every theme — or, written without one, drops the declaration altogether. Four did: the hover of an accordion header in the gallery, the head of a table in the side panel, and the hover border and the scrollbar thumb of the filter panel were never painted. The others were pinned on their light value: in the dark theme the share dialog stayed white, and the title of an inactive legend group was black at half opacity on a dark panel. Each read is realigned on a token the themes set — --gl-color-bg-surface, --gl-color-bg-surface-muted, --gl-color-border-soft, --gl-color-border-strong, --gl-color-text-muted, --gl-color-text-main, --gl-color-surface-elevated. What you may see: in the light theme, a few greys move by a shade (#f7f7f7 becomes #f9fafb, #666 becomes #6b7280); in the dark theme, those surfaces follow the theme. If you had defined one of the old names in your own stylesheet — --gl-color-bg, --gl-color-bg-alt, --gl-color-bg-subtle, --gl-color-bg-hover, --gl-color-bg-surface-elevated, --gl-color-border-main, --gl-color-border-muted, --gl-color-text-disabled, --gl-color-text-primary, --gl-color-legend-header-inactive-bg, --gl-color-legend-text-inactive, --gl-radius-small, --gl-shadow-strong — it is no longer read: set the theme's token instead. --gl-color-on-accent is still read, as before.

  • A list of the form can be reordered on a touch screen, and from the keyboard (@geoleaf/field-renderer 1.4.3, carried by @geoleaf-plugins/editor 1.6.0). Reordering a list field relied on HTML5 drag-and-drop alone, which no mobile browser fires on touch: on a phone the handle did nothing, in silence. Each row now carries two buttons, move up and move down — 44 px targets on a touch screen, named for a screen reader in the six languages, disabled at the end a row cannot cross. The focus follows the moved row, so the same entry can be moved again without walking the form. The drag stays, for the mouse.

  • A photo taken where the browser refuses to store a Blob is kept as a photo, not as base64 in the feature (@geoleaf/core 3.15.0 with @geoleaf-plugins/editor 1.6.0). WebKit refuses a Blob or a File in IndexedDB for an ephemeral session, on purpose — a private tab is one. The local write failed, the editor fell back on a data-URL, and the whole photo went into the feature's attribute: kept, but out of reach of the upload that runs when the network is back. The write is now made in two steps — the Blob, and on its refusal the bytes with their type, which the same session stores — and the editor's upload retry and preview read both shapes. The attribute holds a gl-img: token in both cases. The second step is taken for a caller that asks for it: storeImageLocally({ …, acceptBytes: true }), which the editor passes. Without it a refused Blob rejects, as it always did. No migration: a record already written keeps its Blob, and nothing changes where the Blob is accepted. ⚠️ A record of the local_images store may now carry bytes (an ArrayBuffer) in place of blob: code that reads that store directly must accept both. ⚠️ The two packages go together to get the fix — an editor 1.6.0 on an older core still falls back on the data-URL, and so does an older editor on core 3.15.0: it does not ask for the bytes, so it is never handed a photo it cannot read.

  • The offline window says which basemap will not be prepared (@geoleaf-plugins/offline-ui 1.7.0, with @geoleaf/core 3.15.0). A basemap marked offline: true on an origin not declared for the preparation (modules.offline.dataOrigins) was listed, ticked and counted in the size estimate, then left out by the download with a console warning. Its row is now greyed, unticked and inert, and its tooltip names the origin and the reason; and when a download still leaves an origin out — the tiles a vector style names, for one — the closing notice says so and names it, instead of announcing a success. The plugin asks the core's rule (GeoLeaf.Storage.prefetchVerdict); on an older core the row is what it was.

  • A confirmation dialog has finger-sized buttons wherever it opens (@geoleaf-plugins/offline-ui 1.7.0, @geoleaf-plugins/editor 1.6.0). Under a coarse pointer the 44 px minimum of Cancel, Confirm and Delete came from the form library's touch rules: a dialog opened by a plugin that does not carry the form — the offline panel's confirmations — kept 36 px buttons. The rule now travels with the dialog itself.

  • The form's close button keeps its shape after another plugin's dialog (@geoleaf/field-renderer 1.4.3, carried by @geoleaf-plugins/editor 1.6.0). Every bundle adopts its copy of the dialog stylesheet at its first dialog; adopted after the form's own sheet, it won a tie and the round close button of the form turned square and changed colour for the rest of the page's life. The form library no longer carries a copy of the dialog's rules — it adopts the dialog's sheets — and what it adds on those classes no longer ties.

  • Moving a point no longer puts back the token of a photo already delivered (@geoleaf-plugins/editor 1.6.0). When a photo taken off-network is uploaded, its URL replaces the token on the entity the device stores — and the layer drawing that entity kept its own copy, token included, until the next reload. A geometry edit is built from that copy: on a gallery holding one photo delivered and another still waiting, moving the point sent the two tokens back, and the URL was replaced, on the device and on the server, by a token whose file had been reclaimed. The upload now writes the URL onto the layer's copy too, announced like any other change of the layer. A gallery whose photos were all delivered was not affected; a gallery already damaged keeps its dead token — the file is no longer on the device.

  • realtime-layer loaded on demand stops with the application (@geoleaf-plugins/realtime-layer 1.0.8). Its lifecycle module was registered only before the first boot. A host evaluating the bundle once the application runs got neither half: after unmount() the sources kept polling their feed, for layers that no longer existed, and the next mount() started a second set. The module is now registered on both loading paths. As shipped the late path does not exist — the application preloads the plugin whenever its profile declares a realtime layer. Requires @geoleaf/core ≥ 3.14.2 for the late path.

  • The map follows a download (@geoleaf/core 3.15.0). A layer declaring offline.enabled is drawn from the device's store, which it read once, when it loaded. A pull — GeoLeaf.Storage.pullLayer(), or the download window — rewrote that store and the layer was not told: an entity the server had deleted left the device and stayed on the map, an edited one kept its old drawing, and a first download left the display file on screen, until the page was reloaded. The layer is now given what the store holds as its pull ends, through the loader's own path, and geoleaf:layer:updated announces it — an open table and an active filter follow. An entity still owed to the server keeps its pending mark. A layer that is not on the map, or is drawn from the network, is not touched.

  • An entity owed to the server shows it at once, whatever its geometry (@geoleaf/core 3.15.0). The pending mark — an orange stroke — was set by the restore of pending edits alone, at boot: an entity captured or edited since was drawn like any other until the page was reloaded. And it was a point's circle stroke only: a line or a polygon owed to the server never showed it. The mark is now set as the edit is queued, when the entity is on its layer, and removed when the server accepts it; a line carries it on its stroke and a polygon on its outline, after a theme switch too. The guide to the offline write cycle presents it.

  • The declared-field diagnostic speaks again when a layer is written (@geoleaf/core 3.15.0). A field a profile declares and the data does not carry is named in the log when the layer loads, and when its style changes — and never again: an OGC refresh, a realtime tick, GeoLeaf.Layers.setData or mergeFeatures could bring data without a declared field, and its reader fell back to a default without a word. Every such write now has the layer judged again, on what it holds and under the style it wears; a burst of writes is judged once, and a field already reported is not reported twice. Nothing is blocked and nothing renders differently — it is a line in GeoLeaf.Log.

  • « Reset » puts the value shown beside a range slider back too (@geoleaf/core 3.15.0). The filter panel's reset moved the slider to its minimum and left the number beside it at the value just cleared.

  • A pane its plugin removed is no longer brought back by its host (@geoleaf/core 3.15.0). While it is open, a plugin's pane is moved into the mobile sheet or the desktop side panel, and given back to its original parent when the host lets go of it. Both hosts gave back every node they had moved, without checking that they still held it: a pane its plugin had just removed — at unmount(), the itinerary pane of routing — was re-attached to the page, and the next opening built a second pane beside it. A host now restores only what is still inside it.

  • A guidance session ends with the application (@geoleaf-plugins/navigation 1.0.3). The plugin registered no lifecycle module: after unmount() the guidance banner stayed in the page, the position watch and the screen wake lock stayed held, and the session went on following positions for a map that no longer existed. unmount() now stops the session, as GeoLeaf.Navigation.stop() does, and the next mount does not resume it. Requires @geoleaf/core ≥ 3.14.2 when the plugin is loaded on demand.

  • The offline window closes with the application (@geoleaf-plugins/offline-ui 1.7.0). Open at unmount(), the window stayed over the page with its listeners, and covered the next application. It is now closed and its listeners are returned; the toolbar button of the next application opens a new one.

  • modules.pwa.offlineDetector.badgePosition is read whatever the offline engine's state (@geoleaf/core 3.15.0). The corner of the connectivity badge was honoured when the offline engine was enabled, and written "topleft" when it was not — the mode where the badge is all the detector does. An application showing the badge alone can now place it.

  • The offline preparation no longer stores a JSON resource that parses to null or to a bare value (@geoleaf/core 3.15.0). Such a body was written to the cache and handed back as the resource. It is now a failed download, named Nothing storable in the response, counted with the others in the preparation's summary.

  • The schema states the default the code applies for data.enableProfilePoiMapping (@geoleaf/core 3.15.0). geoleaf-config.schema.json declared false; the loader has always normalised the POI data unless the key is set to false, and the configuration reference said so. An editor completing from the schema offered the wrong default. Nothing changes at run time.

Documentation ​

  • A reading of the public pages against the code, before this version (@geoleaf/core 3.15.0 and the plugins' READMEs). No behaviour changes; several pages said something the code does not do. map.padding is [horizontal, vertical] — the schema, the profile reference and the configuration guide wrote it the other way round, so a profile written from them had its two margins swapped. map.initialMaxZoom capped nothing, whatever three pages said — it does now, see Fixed; map.minZoom applies with or without positionFixed; map.target is optional; a profile positions the map with bounds orcenter and zoom. The permalink is configured under modules.permalink, not ui.permalink, and the PWA under modules.pwa, not a root pwa key. The connector's HTTPS rule is described as it has been since 1.4.0 in the three core pages that still gave the earlier one. The page of CSS variables listed ten tokens that do not exist: it names the accent family, the focus ring and the font token. worker-src blob: is explained the same way everywhere. The editor README configured the plugin under editorConfig, which is ignored: it is modules.editor. Table.exportSelection() writes the file itself — the table guide said the application had to. The schema gave 12 pt as the default label size; it is 12 px, which is 9 pt. mapOptions.preserveDrawingBuffer, GeoLeaf.Baselayers.refreshBasemap(), GeoLeaf.ThemeSelector.setTheme(id, { keepView }), ensureGeoLeaf and geoleaf:sw:update-waiting gain the reference entry they lacked. One type follows: pullLayer() reports cursorRefused: false when no offline engine is wired, where the member was missing.
  • terrain.default3D says what the code does (@geoleaf/core 3.15.0). Its TSDoc said the user could disable the relief manually: no control does that. The 3D terrain is activated when a basemap whose default3D is true is selected, and deactivated when another one is; without default3D, an enabled terrain is never drawn. The type and the schema also said terrain was valid on vector and tile basemaps only — it is read whatever the basemap type. The profile reference listed a "custom" value for hillshade.demEncoding that the schema has never accepted, and the configuration guide named none of the hillshade.* keys.
  • A typed read of GeoLeaf.Table from a page that is not the table plugin's is guarded (@geoleaf/core 3.15.0). The plugin configuration guide called GeoLeaf.Table.exportLayer() unguarded; the namespace is mounted by the plugin and absent from a page that does not load it. The ambient's own note on GeoLeaf being declared | undefined is rewritten: it said what the choice cost this repository's documentation, which is no longer true, and now says what it asks of an integrator.
  • Three pages describe the keys the code reads (@geoleaf/core 3.15.0). The scale page documented a scaleConfig block nothing reads: the control reads modules.scale, and three of its five defaults were announced false where the code applies true. Its "disable" section turned nothing off — leaving the block out keeps the control, enabled: false removes it. The table page showed a tableConfig block with pageSize and virtualScrolling, two deprecated keys read by nothing, and a row limit of 1000 against 30000: it shows modules.table with the plugin's defaults. The UI components page declared ui.controls, showScale and scaleType: each control is a capability with its own modules.<name> block, and fullscreen is an action of the toolbar.
  • The permalink page writes its configuration where the core reads it (@geoleaf/core 3.15.0). Four blocks declared ui.permalink; the capability reads modules.permalink. The section on "enabling" it is rewritten as what it is: the permalink is active unless a profile turns it off. The path given for the validators named a directory that no longer exists.
  • attributes.titleField says what it titles (@geoleaf/core 3.15.0). The layer schema and the contract said the key "titles the popup and the side panel". It names one entry of fields, and titles the surfaces that entry is displayed on: a side panel whose display.surfaces does not list the field opens without a title — measured.
  • The plugins' READMEs and two core guides state the defaults the code applies (@geoleaf/core 3.15.0, @geoleaf-plugins/editor 1.6.0, geocoding 1.1.4, measure 1.0.10, print 1.3.5, table 1.2.0). The Default column of a plugin README was read by nothing; it is now checked against the plugin's own table of defaults, for the eight plugins that have one, and so are the tables of the core guides that name the block they describe. What the check found:
    • PLUGIN_CONFIGURATION_GUIDE.md announced '180px' and '80vh' as the minimum and maximum heights of the table panel; the plugin applies "20%" and "60%".
    • ui/PERMALINK.md said the permalink is disabled by default. It is enabled by default — a profile turns it off with modules.permalink.enabled: false — and its fields default is the list of seven facets, not "all fields" in prose.
    • The geocoding README gave one French sentence as the default of placeholder; the plugin applies its translated label geocoding.control.placeholder.
    • The editor README had no row for showAddPoi and poiAddDefaultPosition, and the table README none for csvSeparator and csvIncludeGeometry.
    • Five defaults were written as prose or in a notation no program reads (enabledTools of the editor and of measure, tooltipDefaultSize, decimals, the margins of print) and are now the JSON value itself.
    • modules.table.exportFormats, csvSeparator and csvIncludeGeometry enter the parameter inventory.
  • The routing plugin's travel mode enters the parameter inventory (@geoleaf/core 3.15.0). modules.routing.profile — car, foot or bike, "car" by default — was documented in the plugin's README and missing from the inventory. The inventory's rows for the plugins are now checked against each plugin's own table of defaults, which is how it was found; the same check repaired the row of modules.routing.labelField, where an unescaped | in the type cell had shifted every cell after it and made its default unreadable.
  • Comments, messages and reference pages no longer cite internal task numbers (@geoleaf/core 3.15.0, @geoleaf-plugins/editor 1.6.0, @geoleaf/field-renderer 1.4.3, @geoleaf-plugins/table 1.2.0). Source comments, TSDoc, test titles, schema descriptions and several pages under docs/ referred to the numbered tasks of a planning document that is not part of this repository — a reference no reader could follow. Each one is replaced by the reason it stood for, or removed where it only recorded when the line was written. No behaviour changes: for @geoleaf-plugins/table this entry alone is one test comment.
  • The first example of the offline-ui README guards what is optional (@geoleaf-plugins/offline-ui 1.7.0). It called GeoLeaf.Storage.isOffline() and getStats() unguarded, although the façade is inert until the offline engine loads and its members are optional. The index signature removed above is what let it compile.
  • The quickstart tutorial boots as written (@geoleaf/core 3.15.0). Copied file for file, the project of QUICKSTART_TUTORIAL.md drew its map and nothing else: no tooltip, no popup, no side panel, no filter panel. Its layer declared tooltip, popup and sidepanelConfig, three keys no code reads and the layer schema refuses; its taxonomy and its ui.json were written in a layout that predates the current one; and two files it referenced — the style and cluster.json — were never given, so the browser logged a style error. The steps are rewritten in the form the code reads (attributes for the three information surfaces, a named taxonomy, modules.filter), every file is given, and each block that is a whole profile file is now validated against its schema when the documentation is checked. The "Complete Example" of config/LAYER_CONFIG.md carried the same three keys and follows.
  • Four parameters the code reads enter the configuration reference (@geoleaf/core 3.15.0). modules.offline.enabled — the gate of the offline capability itself —, modules.offline.banner.enabled, modules.offline.drain.pollIntervalMs and modules.pwa.offlineDetector.badgePosition were declared by their capability and listed in neither the configuration guide nor the parameter inventory. The inventory's rows for the in-core capabilities are now checked against what each capability declares, which is how the four were found; the same check corrected the default written for modules.permalink.fields, announced as ten fields for a list of seven.
  • Three performance keys that nothing reads leave the guides (@geoleaf/core 3.15.0). maxConcurrentLayers, layerLoadDelay and fitBoundsOnThemeChange were still shown in examples and recommended by two troubleshooting tips against slow loading — advice that changed nothing, in a block the profile schema refused. The two guides that carried them have since left this version altogether (see Removed); PROFILE_JSON_REFERENCE.md, which said the whole performance block was removed, describes its one key.
  • The configuration guide no longer calls map.target a CSS selector (@geoleaf/core 3.15.0). It gave "#geoleaf-map" as the default and described both map.target and map.id as selectors; the boot resolves them with document.getElementById, so the value is a bare id and a leading # finds nothing. The guide also listed map.mapOptions as a pass-through to the engine's constructor: no code reads that key from a profile, and the row is gone.

[3.14.2] - 2026-10-04 ​

Removed ​

  • The security.httpsOnly configuration key, which nothing read (@geoleaf/core 3.14.2). The key was typed (SecurityConfig, GeoLeafConfig.security) and shown by two configuration guides as the way to refuse http: resources. No code ever read it, and the JSON schemas had already dropped it: a configuration that set it changed nothing while reading like a hardening. The type and the two passages are gone. validateUrl(url, base, { httpsOnly: true }) is unchanged — it is an option of the call, and hardens the call that passes it; its error message no longer names a configuration key. To refuse http: for a whole deployment, use the deployment layer: CSP upgrade-insecure-requests, HSTS.

Fixed ​

  • A vector-tile layer declared polyline or multiline draws its lines (@geoleaf/core 3.14.2). Both names are admitted by the layer schema, and polyline is the one the configuration guide gives a line layer. The vector-tile path chose its rendering from lists of its own, which held neither: such a layer was created without a single rendering layer — nothing on the map, no error, no warning. The vector-tile path now reads the same geometry vocabulary as a GeoJSON layer, so every value the schema admits is drawn. A geometry that names no family at all — a typo handed to GeoLeaf.Layers.create(), which does not validate against the schema — still draws nothing, and a warning now says so.

  • An application mounted again gives its plugins back, and takes them down when it is unmounted (@geoleaf/core 3.14.2, @geoleaf-plugins/connector 1.3.5, editor 1.5.7, geocoding 1.1.3, measure 1.0.9, position-share 1.0.2, print 1.3.4, table 1.1.3). GeoLeaf.mount() unmounts the application and boots it again; a plugin was reached by neither, unless it had been loaded before the first boot — and then for two plugins only.

    • The toolbar lost the button of every plugin loaded on demand. Both toolbars are rebuilt at each boot, and offered the slots declared with registerLazyForAction() only for the plugins not loaded yet: once the user had opened the table, the editor, a measure or the print flow, the next mount drew the toolbar without them. The slots are now drawn whether the bundle is loaded or not; a plugin loaded before the boot still declares its own.
    • A plugin loaded after the boot was never torn down. The module registry only tore down the modules it had initialised itself. A lifecycle module registered after init() is now torn down with the others, and initialised like them from the next boot on.
    • What stayed in the page after unmount(): the table panel, open, with gl-table-open on <body>; the menus of the editor and of the measure tools, in the map container; a print area being selected, still catching clicks; the search bar of a geocoding plugin loaded on demand. Each is now removed with the application.
    • What did not work on the next map: the editor and the measure tools kept the map of the previous application, and a tool armed drew nothing; the credential button of the connector was not put back; a configured connector kept window.fetch but the tiles of the new map left without their token until a basemap switch. All are wired again at each boot. On the lazy path, the editor also wired itself a second time at the next geoleaf:map:ready — it is now wired once per map.
    • A position being shared kept being sent. The emission loop of position-share is a timer of the plugin: isEmitting() still answered true once the application was unmounted, and positions left again as soon as a location watch was active. unmount() stops it; emission started by hand is not resumed on the next application.

    One thing is left alone on purpose: unmount() does not undo GeoLeaf.Connector.configure(). The session is the host's, set once for the page — window.fetch keeps its token, and the next application is authenticated without configuring again.

    On @geoleaf/core 3.13.0 to 3.14.1, a plugin of these versions loaded after the boot is not torn down, as before, and the registry logs a warning when it registers.

[3.14.1] - 2026-10-03 ​

Fixed ​

  • A hatched layer keeps its pattern when a basemap switch makes the engine rebuild the style (@geoleaf/core 3.14.1). A hatch is an image GeoLeaf draws and hands to the engine, and an image is not part of the style. MapLibre first tries to diff the outgoing and incoming styles, and keeps its images when it can; when it cannot — an incoming vector style that declares terrain or centerAltitude — it rebuilds the style from scratch, with an empty image store. The layers were carried into the new style, the patterns they name were not: a pattern-only fill drew nothing until the page was reloaded. The patterns are now put back after every style change, for a profile with or without an icon sprite. A switch the engine can diff was never affected.

  • A gallery's photos follow the same road as a single photo (@geoleaf/field-renderer 1.4.2, @geoleaf-plugins/editor 1.5.6). The gallery field had been left out of it, on two counts. With an upload endpoint, a photo taken off-network was stored without the field it belongs to: once the network was back the file was uploaded, its local copy reclaimed, and nothing wrote the returned URL onto the feature — the feature kept a gl-img: token that designated nothing, and sent it to the server. With no endpoint, the gallery wrote a blob: object URL into the attribute, a value that dies with the page. The gallery now hands the host its field, with or without an endpoint, and the editor writes each URL back in place of its token — in the list, found by value, so a gallery reordered or trimmed while a photo waited is left as the user arranged it. A token the attribute no longer holds is not written back at all, which also applies to a single photo replaced before its upload landed. Photos already lost to this defect are not recovered: their tokens designate files no longer on the device.

  • The preview of a photo waiting for its upload is displayed (@geoleaf/field-renderer 1.4.2). A host that keeps a capture answers its token with an object URL on the stored file; the library ran that URL through the protocol check it applies to values read from data, which does not admit blob:, and the preview came out empty — a blank strip in the form, for the single photo and the gallery alike. An object URL answered by the host's preview resolver is now displayed, in the form and in the lightbox. Nothing else is admitted: any other answer of the resolver, and a blob: value read from the data, are checked as before.

[3.14.0] - 2026-10-02 ​

Added ​

  • defaultVisible and cluster are declared by the layer schema, for FlatGeobuf layers (@geoleaf/core 3.14.0). The FlatGeobuf plugin reads both at the root of a layer's configuration, and its README shows them; the layer schema is closed and declared neither, so a configuration copied from that README failed profile validation. Both are now accepted, as booleans, on a layer declaring plugin: "flatgeobuf" — and refused on any other layer, where nothing reads them: visibility there belongs to the themes, and clustering to the clustering object. The published schema and the profile types generated from it carry the two keys. The plugin itself is unchanged.

[3.13.1] - 2026-10-01 ​

Removed ​

  • The "profile loaded" toast, which never showed. Its listener was laid while the modules start, after geoleaf:profile:loaded had fired — once per page, a profile switch reloading it. The library therefore never rendered that toast; only an event emitted by hand reached it. The listener, its retry on geoleaf:map:ready and the toast.profile.loaded label are gone. The event itself is unchanged, and the "theme applied" toast still follows a boot.

Fixed ​

  • @geoleaf-plugins/table 1.1.2 — a KML or GPX export names a feature as the table does. A feature with no identifier of its own is given a synthetic one, __gl_row_<n>. The table counted the synthetic identifiers handed out so far; the KML and GPX exports passed the position in the exported array. On a layer mixing identified and unidentified features — and on any selection — the same feature was __gl_row_2 in the table and __gl_row_5 in the file. One function now numbers a layer for both, and a selection is exported with the identifiers its features already have. Only the <name> of a placemark or waypoint changes; GeoJSON, CSV and Excel exports never carried it.
  • The editor's drawing chunk carries one copy of Terra Draw, not two (@geoleaf-plugins/editor 1.5.5). The MapLibre adapter resolved its terra-draw peer against an older copy than the one the editor declares, and both were bundled in the chunk loaded at the first drawing tool. One copy remains — 1.32.3, the one the editor already ran. The chunk loses about 6 KB (1.3 KB gzipped).
  • The offline-ui README describes the plugin that ships (@geoleaf-plugins/offline-ui 1.6.3). It still listed the storage, the write queue and the connectivity detection as the plugin's — they are the core's —, said the core was a dependencies entry when it is a peer, and documented getStats() with a sync: { pending, failed } block and a tileCacheSize that do not exist. The statistics are features, outbox and conflicts counts.
  • A vector-tile layer declared "mixed" confines each of its sub-layers to its geometry. Its fill, line and circle sub-layers were built on the same source-layer with no filter, and the engine does not check the geometry type when it fills a bucket: a circle was drawn on every vertex of the lines and polygons. They now carry the geometry guard the GeoJSON layers already had. A layer of a single geometry kind is unchanged.
  • Baselayers.init()'s example passed an option that does not exist. Its @example, its @param and the Baselayers page said defaultKey; the option is activeKey. The ghost key was read by nothing, and the example activated the first registered basemap instead of the one it named.
  • pulledPartial no longer claims the runs the cap cuts short. Its description said a pull "the hard cap cut short" was reported pulledPartial. Such a run goes to its end and is reported pulled; the status is the one of a pull the caller aborted or the page left mid-run. The offline-ui overview (@geoleaf-plugins/offline-ui 1.6.3) also described stored photos as base64 strings ending synced: they are Blobs carrying an uploaded flag, purged once uploaded.
  • Three descriptions of the write path said something else than the code. The schema said a layer whose write target is disabled "has no outbox": its edits are kept locally, then set aside as layerNoLongerWritable. It did not say that an absent write.properties sends every property. And the editor's README (@geoleaf-plugins/editor 1.5.5) described persistence.mode: "auto" as an online/offline detection, which it has not been for a while — it also now says that persistence.dialect (default "rest") and a layer's write.dialect (default "collection") govern two different paths.
  • A profile that declares no themes no longer asks for a themes.json. The theme selector, mounted on geoleaf:app:ready, still asked the theme loader, which fell back to the legacy profiles/<id>/themes.json: a 404, retried one second later, and a console error on every page load. A modular profile without a themes block is now recognised before any request. geoleaf:themes:ready is still emitted for it, without an error.
  • A shared link keeps its layers and its filter on a profile that declares no themes. When the link carries no theme, the layer visibility and the filter it carries were restored on the first geoleaf:theme:applied — an event a profile without themes never emits, so they were lost without a word. They are now restored right after geoleaf:app:ready when no theme was applied.
  • The offline database's v3 migration can no longer abort the upgrade. It rewrites the uploaded flag of the stored images and runs only for a database older than v3. Its comment promised never to fail the upgrade over the migration, and it did not hold: a failed cursor read was logged without being cancelled, a failed rewrite had no handler, and an exception in its success handler was not caught — each aborts the version change, hence the open, and the engine falls back on a store with no persistence. The three are now guarded, as the v7 repair already was.
  • @geoleaf-plugins/realtime-layer 1.0.7 — installing the plugin no longer installs a command-line toolchain. gtfs-realtime-bindings was declared as a runtime dependency although the plugin bundles it in its gtfs-rt chunk and no published file imports it: every install pulled it, and with it protobufjs-cli and its own tree, which nothing loads. It is now a development dependency. The published files are unchanged.
  • @geoleaf-plugins/print 1.3.3 bundles DOMPurify 3.4.16. The PDF export's lazy chunk embeds the copy its PDF library depends on; it moves from 3.4.13 to a release that closes a low-severity advisory. Nothing else of the print bundle changes.
  • @geoleaf-plugins/print 1.3.3 — a printed map credits its basemap. The footer asked the map for an attribution text through a method that does not exist, and the off-screen map is created without an attribution control: no printed page carried the credit of any provider. The footer now prints the credit the active basemap declares — attribution, or the legacy options.attribution — as plain text, before the description. A credited basemap reserves the footer band by itself, so the map zone is 10 mm shorter on a page that had no description.
  • Core.destroy() called from a geoleaf:app:ready listener no longer leaves an uncaught error. The scale bar and the coordinates readout mount on that event; a destroy run by a listener laid before theirs let them mount on the map just destroyed — "map is not ready", thrown to the page. Both are disarmed when the last map is destroyed. Core.destroy() is still not an unmount: an application started by boot() is taken down by GeoLeaf.mount().
  • A creation the server acknowledges without its row is no longer "synchronised" with no identity. A 2xx answered with [] came out a plain success: the entity was marked synchronised without a server identity, and every later edit of it was never sent. GeoLeaf now looks the row up by its client identity, as it does after a 409: found, the identity is recorded; absent, the server wrote nothing and the edit is retried, then set aside as rejectedByServer; unreadable, it is retried. An answer with no readable body — a 204 — keeps its reading, which the server contract states.
  • An edit that fails is attempted four times, not three. The retry delays were announced as 30 s, then 2 min, then 8 min, and the last one was never waited: with three attempts in all, the third failure set the edit aside. A server away for three minutes therefore set the whole queue aside until someone pressed "retry all". With four attempts the three delays apply, and an edit is set aside after about ten minutes of failures instead of two and a half.
  • "Sync now" is sent during the pause that follows a 401. The sync bar's button and the send button of the offline window went through the automatic path, which a dead session pauses: they did nothing, without a word, while still being offered. A press is the operator saying the session is back, and it now runs a pass. If the session is still dead, that pass stops at its first 401 and sets one more entry aside, as any pass does.
  • A layer with clustering switched off keeps the filter's GPU path. The filter took any layer carrying a clusterRadius or a disableClusteringAtZoom for a clustered one, without reading the decision: a layer declaring clustering: { enabled: false } beside its radius had its source re-fed whole on every filter pass. The decision is read first; a layer that says nothing is still treated as possibly clustered.
  • Switching from a vector basemap to a WMS, an image or a hillshade basemap shows it. After a vector basemap the incoming one was built by the XYZ tile constructor whatever its type; these three forms carry no tile template, so the map got an empty raster source and still announced the change. The switch now builds the source of the basemap's type, as a switch between raster basemaps always did.
  • The shipped schema of a layer said that a layer reading the local store "is truncated just like the one that fills it". Only the pull reads offline.maxFeatures: on a layer that declares no offline.source, maxFeatures and maxAgeMs have no effect. The $comment of the offline block says so.
  • The contract described the layerNoLongerWritable reason as "the layer stopped being editable between capture and push". Its only producer is a write target that is absent, switched off or without an endpoint; edition is not read again when an entry is pushed. The TSDoc of QuarantineReason says what the reason covers.
  • A token store the browser cannot read no longer stops configure() (@geoleaf-plugins/connector 1.3.4). A failed read — a request that errors, or a store missing from a database of the same version — rejected instead of answering "nothing stored", and the rejection reached configure() before the fetch interceptor was installed. The read now degrades as the write and the delete already did, and the database connection is closed on a failure as well.
  • A refused renewal is asked once, not three times (@geoleaf-plugins/connector 1.3.4). A refusal was forgotten as soon as it was received: on an expired session configure() asked for the renewal, the read before the first request asked again, and the 401 path of that request a third time, before the session was declared ended. The refusal is now remembered for as long as the refused token is the one stored — a sign-in, a sign-out or a reload forgets it.
  • A sign-in is held to the rules of a renewal (@geoleaf-plugins/connector 1.3.4). The 15-second budget of a sign-in stopped at the response headers: a body that stalled after them left the login window waiting for as long as the connection stayed open. It now covers the body. The answer was also read less strictly than a renewal's — an expiresIn of 0 or below, or a token that is not a string, was accepted and the session stored already expired. Both are refused, as a renewal refuses them.
  • Two login windows can no longer stack (@geoleaf-plugins/connector 1.3.4). Only the guided reconnection checked that a window was already open; configure() on an absent session, openLoginModal() and the credential button each opened their own. Two windows then carried the same element ids and one promise each: signing in through one left the other open, and closing the one configure() had opened rejected it on a valid session. A call made while the window is on screen now joins it and shares its promise.
  • A request whose session could not be renewed gets the server's own 401 back (@geoleaf-plugins/connector 1.3.4). In every branch that does not replay the request — a refused renewal, one that could not conclude, a host with no token to give — the interceptor answered with a synthetic 401 carrying neither headers nor body. The server's response is returned untouched: a WWW-Authenticate challenge now reaches the caller.
  • A form that is saving can no longer be closed under its write (@geoleaf/field-renderer 1.4.1, bundled in @geoleaf-plugins/editor 1.5.5). While onSave or onDelete was in flight the three buttons were disabled, but the cross, Escape and a click on the backdrop still closed the form — a cancel, which withdrew the drawn shape while its write was landing. A non-forced close is now ignored until the write settles, and the cross is disabled with the buttons.
  • Two gestures of the form at the finger (@geoleaf/field-renderer 1.4.1, bundled in @geoleaf-plugins/editor 1.5.5). A photo opened from the form showed BEHIND it — its lightbox carried an inline z-index below the form's overlay. And a tap on the tags zone, beside the pills, did not give the focus to its input although the zone shows a text cursor.
  • Ctrl+Z no longer undoes the creation whose form is open (@geoleaf-plugins/editor 1.5.5). The shortcut skipped text fields only: with the focus on a button of the form, or fallen back on the page after a click on the backdrop, it undid the shape under its own form, and a save then persisted a feature no longer on screen. Undo and redo are ignored while a modal dialog is open.
  • GeoLeaf.Measure called before the map says so (@geoleaf-plugins/measure 1.0.8). A call that came before the map existed returned without a word; one that came while the map style was still loading let addSource throw — from a toolbar listener, an uncaught error on the page. Both now warn in the console, naming the signal to wait for, and the next call initialises the tool as before.
  • A download that did not happen is no longer announced as done (@geoleaf-plugins/offline-ui 1.6.3). cacheProfile resolves with an error when nothing was downloaded — a second click while a stopped download was still finishing its page, a cache switched off. The control read cancelled only, and showed "0 resources", then "profile downloaded: 0 B". It now reports the failure, with the reason.
  • The "clear the cache" confirmation says what it deletes (@geoleaf-plugins/offline-ui 1.6.3). It asked to delete the offline cache "for this profile"; the same gesture also empties the basemap tile cache, which every profile shares. The message says so, in the six languages. What is deleted does not change.
  • The README of @geoleaf-plugins/connector (1.3.4) documents GeoLeaf.Connector.logout() and the geoleaf:connector:signed-out event, which it had never named.

[3.13.0] - 2026-09-29 ​

Added ​

  • GeoLeaf.mount(el, options) — the application a host mounts, unmounts and mounts again. It boots as GeoLeaf.boot() does, with the same options, and returns at once a handle: ready (a promise settled by the boot: resolved on geoleaf:app:ready, rejected with a GeoLeafMountError — reason, phase — on a failure, an abort or an unmount before it was ready), unmount(), on(event, handler) (the page's event bus, every subscription removed at unmount) and getMap(). unmount() takes the WHOLE application down — modules, controls, panels, map, and the state they held — and the next mount() gives it back whole. el is the map container, an element or its id. Still one application per page: a mount() while one is alive — mounted by mount() or by boot() — unmounts it first, and its handle goes inert. Core.destroy() is unchanged: it removes the map alone, and is not an unmount.
  • boot({ maplibregl }) (and mount(el, { maplibregl })): the MapLibre GL JS engine handed over as an option. MapLibre 6 no longer sets globalThis.maplibregl; the core puts it there, where every reader looks for it.
  • reason: "engine" on geoleaf:boot:failed: no MapLibre GL JS engine was found when the map was built. It failed as "map" before — the answer also given for a missing container. The union is wider: a switch that covered every reason needs the new case.
  • geoleaf:boot:aborted carries reason: "unmounted" when an application is unmounted while it is still starting.
  • ModuleInitOptions.shouldContinue: asked before each module's init(), false stops the initialisation there. ModuleRegistry.destroy() now tears down only the modules that started, and is refused while init() is running.
  • The profile schemas and their types ship with the package. The ten JSON Schemas that judge the files of a profile, at @geoleaf/core/schemas/<name>.schema.json — byte for byte the ones the repository validates its own profiles with — and TypeScript types generated from them at @geoleaf/core/schemas (type-only): GeoLeafProfile, GeoLeafRootConfig, GeoLeafLayerConfig, GeoLeafLayerStyle, GeoLeafBasemaps, GeoLeafCoreFeatures, GeoLeafLayersIndex, GeoLeafDataMapping, GeoLeafThemes, GeoLeafUIConfig. A profile can now be validated with what npm gives. The types are an upper bound of the schemas — every file a schema accepts type-checks; conditional and presence rules are not expressed, the verdict stays with the schemas. They compile under ajv's strict: true, with allowUnionTypes. Their descriptions and $comments lost their pointers to the project's internal notes. Under modules, the profile schema names exactly the plugin blocks a plugin reads, and leaves each one open: neither the core nor a plugin reports a key it does not know, so a misspelled key is silently ignored.
  • A field a profile names and its data does not carry is said when the layer loads. A profile names the properties of its data in free text, and a name the data lacks read undefined: the taxonomy fell back to the category's icon, an attribute row vanished, a style rule never matched — without a word. Each field a layer's readers declare — categoryField / subCategoryField of its taxonomy, a style rule's when.field, a displayed attributes.fields[].field, the label's field, searchable.fields, the field / subField of a filter descriptor that lists the layer — is now looked for in up to 1 000 of its features, evenly spread, under the rule of the code that reads it. One carried by none is named in one Log.warn per layer, with its configuration key and, when only the case differs, the name the data carries. A property present with null counts as carried; an empty layer is not judged; each field is said once per page, and again after an unmount. A switched style is judged too. Nothing throws and nothing renders differently. Not judged: vector tiles, a layer loaded by a plugin, and the writes after the first load.

Changed ​

  • boot() after an unmount starts the application again. boot() while an application is mounted is still refused, as before.
  • @geoleaf-plugins/realtime-layer 1.0.6 and @geoleaf-plugins/geocoding 1.1.2 unmount with the application: the real-time sources stop, the search bar goes. Both register their teardown before the first boot; loaded after it, they are not unmounted. A plugin that wires itself once, when its script loads, does not come back after a remount.

Fixed ​

  • What an application left behind it once torn down — found by making the teardown reachable, each measured: the next map got no basemap; the permalink kept syncing the destroyed map; the theme was not fitted again; onPerformanceMetrics fired on the first boot only; the proximity filter released none of its document listeners; the desktop panel's theme buttons left a listener on window each; the reveal of a boot unmounted before it revealed could fire on the next boot; the configuration of the next boot was merged into the previous one; a capability whose teardown threw never started again.
  • The offline write cycle's members of GeoLeaf.Storage are typed. applyEdit, mayEdit, canQueueWrites, pushOutbox, requeueQuarantined, requeueAll, discardQuarantined, requeueableReasons, listConflicts, clearConflicts, whenReady and Storage.DB.listPendingEdits were typed unknown by the published namespace, so a TypeScript project could not call them without a cast. They now carry the facade's own signatures.
  • The documented example of discardQuarantined() did nothing: it listed through Storage.listPendingEdits, which does not exist (the method is on Storage.DB), and read id where the entry carries entryId. Fixed, with the example of requeueableReasons(), which read a motive no public listing returns.

[3.12.0] - 2026-09-27 ​

Added ​

  • geoleaf:layer:updated ({ layerId }): a layer's store changed through GeoLeaf.Layers — once per call of setData, clear, addFeature, removeFeature (when it removed something), updateFeatureId, mergeFeatures, and patchFeature with { rerender: true }. Not for a silent patchFeature, a visible subset or a layer's load — nor for a writer of a whole collection outside GeoLeaf.Layers: a real-time layer's ticks, an OGC layer's auto-refresh, a direct GeoLeaf.GeoJSON.updateLayerData. The open table and the active filter now listen to it.
  • GeoLeaf.Layers.hideFeatures(layerId, ids | null) hides features, matched on properties.id — and, on a vector-tile layer, on the tile's own feature id too — without touching the store or the layer's filter: the layer shows what passes its filter and is not hidden (on a clustered layer, a hidden point still counts in its bubble). The editor hides the feature it edits this way.
  • IMapAdapter.setLayerFilter(id, filter, owner?): a layer holds one filter per owner and draws what passes all of them. The default owner, "filter", is the one every existing call writes.
  • THIRD_PARTY_LICENSES.txt in every package that bundles third-party code — the core and @geoleaf-plugins/cog 1.0.5, editor 1.5.4, file-import 1.0.6, flatgeobuf 1.0.4, measure 1.0.7, navigation 1.0.2, print 1.3.2 and realtime-layer 1.0.5. Minification had stripped every license comment of the code these packages bundle, and their tarballs carried GeoLeaf's own license alone. The file lists what the built bundle actually carries, with each license text as its authors ship it — or, for a package that ships none, the standard text of the license it declares, said as such — followed by the license comments of the bundled source files, and, for a package published already built (jsPDF, Terra Draw), of the original sources its own source map carries: the notices of the code a package vendors (Adobe's JPEG encoder and Google's WebP decoder in jsPDF, rbush and Turf in Terra Draw, pdf.js code in geotiff, Zstandard in zstddec) live there and nowhere else. They are copied as those sources carry them — where an upstream notice is reduced to its copyright line or to an attribution, so is the copy.

Fixed ​

  • A filter set on a clustered layer no longer draws its clusters as points and its points as bubbles (IMapAdapter.setLayerFilter). The caller's filter replaced the predicates a clustered layer's sub-layers are built with, and clearing it removed them for good. The filter is now composed with the one each sub-layer was built with, which also keeps the geometry guard and an icon layer's own filter. A bubble carries none of its points' properties, so a filter that requires a property value still hides every bubble, while one that passes a feature lacking it — hideFeatures' exclusion — keeps them (the core's own filter re-feeds a clustered layer instead).
  • The open table follows its layer (@geoleaf-plugins/table 1.1.1, with core 3.12.0). A feature created, edited or deleted did not reach the open table until the next filter or visibility change.
  • An active filter judges a feature added, edited or restored. Such a feature was hidden even when it passed — or, on layers the filter re-feeds, shown even when it failed — and the « no feature matches » line kept the previous verdict.
  • Editing a feature under an active filter no longer lifts the filter (@geoleaf-plugins/editor 1.5.4, with core 3.12.0). Selecting a feature put every filtered-out feature back on the map, and releasing it cleared the filter while the panel still showed it active. With an older core the editor behaves as before.
  • The editor deletes the selected feature from its interface (@geoleaf-plugins/editor 1.5.4). Deleting from the pill's button or with the Delete key, then confirming, deleted nothing: no geoleaf:editor:feature-deleted, no write, the feature still in its layer, and no message. Removing a selected shape makes the drawing engine deselect it first, and the editor's deselect handler put the original back as for an abandoned edit, then the engine threw. After a move, the deletion did leave, but the move was also written as an update and the feature announced by geoleaf:editor:feature-saved. The selection is now released before the shape is removed: the deletion is the only write, the feature leaves its layer once the write answers, and deleting a creation still in the offline outbox withdraws it — nothing is sent.
  • A deletion confirmed from the keyboard is carried out (@geoleaf-plugins/editor 1.5.4). With the select tool armed, Enter on the confirmation's own button was taken for « leave the select tool »: the button did not activate, the selection dropped under a dialog still open, and its « Delete » then deleted nothing, without a word. Delete pressed inside the dialog opened a second confirmation over the first. A key pressed in a modal dialog now belongs to the dialog. And the drawing engine's own Delete binding, which removed a selected shape with no confirmation, no undo and no write, is switched off — Escape still deselects.
  • Enter presses the tools pill's buttons (@geoleaf-plugins/editor 1.5.4). With the select tool armed, Enter on the pill's Delete, Undo or Redo button left the select tool instead, and the button never activated. Enter on a button or a link is now the button's or the link's.
  • The pending-sync badge leaves an entity once it is pushed. At load, the entities still owed to the server are put back on their layer with the orange « pending » stroke. When the drain then pushed one, the server's answer reached the device's store but not the layer: the stroke stayed until the next load, saying « owed » of an entity the server already held. It now goes as soon as the push is answered — unless the entity was edited again while the push was in flight, in which case it is still owed and keeps it.
  • A vector-tile layer is created once at boot, without an error. The profile loader reached the layer while the map's own style was still loading, and MapLibre refused the source with « Style is not done loading »: the layer was logged as failed, and only a later pass of the theme applier created it. Its creation now waits for the map's style.
  • The editor no longer rewrites a map source it cannot find (@geoleaf-plugins/editor 1.5.4). When a saved or deleted feature was not in its layer's store, a fallback rewrote the map source instead — looking for a source name the core never uses, so it found nothing and only warned. It is removed: the original is shown back, and the write, which has already left, reaches the layer at its next load. On a vector-tile layer, which keeps no feature, this is said as information, not as a warning.
  • A device that edited existing features before 3.4.0 no longer draws them twice. Until 3.4.0, modifying an existing feature through the offline queue wrote a second record for it, which no later download could merge: the layer drew the feature twice. The local database moves to version 7, and its upgrade merges each pair once — keeping the record that carries unsent work, with its server identity back, and removing the other. When both carry unsent work, nothing is removed and the pair is logged.

[3.11.2] - 2026-09-27 ​

Removed ​

  • storage.notif.cacheEvicted leaves the dictionaries of @geoleaf-plugins/offline-ui (1.6.2). The core announces a budget eviction itself, under the same key in its own six dictionaries, and a core label is read before a plugin one: the plugin's copy was never shown, with any core version. Nothing changes for an integrator — a profile's labels override of the key still applies.

Fixed ​

  • A feature created with the editor is in its layer at once, under its identity (@geoleaf-plugins/editor 1.5.3). Modifying and deleting already went through the layer's store (GeoLeaf.Layers); creating did not. A point placed with « add a point » vanished with its marker until the next load. A drawn shape stayed in the drawing layer with no identity, so moving it was never saved, and deleting it left its creation queued, back at the next load. Nothing found either one: not GeoLeaf.Layers.search, not getFeatureById, not the session export. The created feature now enters its layer's store under the identity its write returned, and the layer draws it. That identity is the server's id, or, for a write the offline outbox holds, the client key the core minted (loc:…), kept for the rest of the session. A drawn shape is then handed over to its layer: it is selected through the layer like any other feature, and its later edits reach the outbox or the server under that identity. The drawing stays only when the layer cannot take the feature: a vector-tile layer, a layer the store does not hold, or a backend answering a creation without an id. The open table and an active filter do not follow the new feature until their next refresh, as for an edit.
  • geoleaf:editor:feature-saved carries the identity of a creation. featureId was "" for any creation the outbox held; it is now the client key (loc:…).
  • A queued creation comes back once, under its identity, at the next load. The device stores a creation as the form submitted it, with no id. The restore of pending edits put it back on its layer without one: one more copy per restore pass (up to two per boot), none findable by getFeatureById, none editable. The device-first read of a layer served it the same way. Both now give the feature its record's identity: the server's id once it has one, else its client key.
  • The client key never leaves as an attribute. An edit of a created feature sends its properties back, the client key among them. Stored as an attribute, it left for the server as id: "loc:…" on any layer declaring no property whitelist. It is now kept out of the stored feature: the key already travels as the client identity (local_id).
  • An edit no longer sends back a photo token already delivered (@geoleaf-plugins/editor 1.5.3). Once a photo is uploaded, its URL is written on the stored feature, while the copy the layer holds still carries the token. An edit sent that token back, and it replaced the URL.

@geoleaf/field-renderer 1.4.0 · @geoleaf-plugins/editor 1.5.2 - 2026-09-27 ​

Published without a core release: @geoleaf/core stays at 3.11.1.

Removed — BREAKING: the dropdown cascade of @geoleaf/field-renderer 1.4.0 ​

createFieldRendererBridge no longer filters a dropdown's choices by the value of another one. A field carrying dependsOn and optionsByCategory now renders its own options, like any dropdown; the two keys are ignored.

Why it goes, and without an announcement. Its one reader, the category → subcategory adapter of the former @geoleaf-plugins/addpoi, left with that plugin. No profile can declare it: the layer schema never carried either key and refuses unknown ones. No consumer measured uses it. And it was wrong on the path 1.3.0 had just made usable off-network: when either list loaded from a URL (fetchOptions), the cascade kept listening to, or filling, a <select> the list had already replaced. It also filtered nothing when a form opened on a value already chosen, and without an emptyLabel it showed one choice while the saved value was empty. Fixing it meant rewriting it and opening two profile keys for a feature nobody asked for. As with createFocusTrap in this same library, the removal is direct.

@geoleaf-plugins/editor 1.5.2 bundles the library and ships the same change. Its forms never reached the cascade.

[3.11.1] - 2026-09-27 ​

Fixed ​

  • The filter panel says when the filter keeps no feature. A filter that matched nothing hid every feature without a word, and an empty map could not be told from a failure. The panel now reads « Aucune entité ne correspond aux filtres » (key ui.filter_panel.no_match, in the six dictionaries, overridable through the profile's labels) between its controls and its buttons, and clears it as soon as an application keeps a feature. The count covers every feature the filter judged: every loaded layer, a hidden one included, and no vector-tile layer. When nothing was judged (no layer loaded, or vector-tile layers only), the panel stays silent, since the filter is not what empties the map. A permalink restore (GeoLeaf.Filter.applyFilter) shows the same message. The line is a live region (role="status"), so a screen reader announces it. geoleaf:filters:applied keeps its empty payload.

[3.11.0] - 2026-09-26 ​

Added ​

  • The pre-departure check says the write session. GeoLeaf.Storage.preflight() now carries session — { state, expiresAt }, state being "valid", "expired" or "absent" — as the one holding the token tells it, or null when nobody does. A session expired or absent makes the verdict degraded: captures made off-network will wait for a sign-in. A valid session leaves the verdict as it is: when it expires is reported, but whether that falls within the trip is not known here. A missing session never makes a device notReady, since no entity leaves the device with it. @geoleaf-plugins/connector 1.3.3 tells the session it manages (auth.endpoint), read as stored and never renewed; @geoleaf-plugins/offline-ui 1.6.1 shows it in the offline window.
  • GeoLeaf.Sync.registerSessionReader(reader) — registers the reader of the write session; null removes it. The core authenticates nothing and reads no plugin's namespace: the one holding the token tells it. A host that hands its own token to the connector (getToken) is the only one who knows its expiry, and registers here to say it. One slot, the last registration wins. The reader must not renew the session; one that throws, answers outside the contract, or does not answer within two seconds leaves session at null without holding up the check. The shape is exported as WriteSession from @geoleaf/core/contracts/sync.contract.js.

[3.10.6] - 2026-09-26 ​

Fixed ​

  • The desktop side panel's tabs follow ui.language. Without a profile title, the three tabs of the panel (≥ 1440 px) were named « Filtres », « Couches » and « Legende » in every language, while the mobile sheets were translated. They now read the same translated labels as the sheets (sheet.title.filters, sheet.title.layers, sheet.title.legend), which a profile's labels can override. In French, the legend tab reads « Légende ». A title set by the profile (modules.filter.title, layerManagerConfig.title, modules.legend.title) still takes precedence, and it names the panel header, the mobile sheet and the tab in one language: to get translated tabs, leave these three keys out.
  • A profile without a default theme boots with its legend, scale bar and coordinates. When themes declared no defaultTheme (or when the profile had no themes at all), the app was revealed before the legend, scale, coordinates, filter and theme-selector capabilities had started listening for geoleaf:app:ready. None of them mounted, GeoLeaf.Legend.getAllLayers() stayed empty, and nothing was logged. geoleaf:app:ready now fires once every module has started, whichever way the profile declares its themes, and a capability that starts after it mounts at once. Declaring a defaultTheme is no longer needed as a workaround.
  • A profile whose themes declares no defaultTheme starts exactly as if it declared its first theme. The first theme was applied, but every other layer kept loading in the background and stayed visible over it. On the demo profile, that meant 19 to 21 layers for a 7-layer theme, and a different count from one load to the next. Only the first theme's layers now load at startup, the others load when their theme is chosen, and the loading veil lifts once the theme is applied. The legacy config.defautTheme key is honoured wherever a default theme is read, after the root defaultTheme.
  • A modular profile without themes no longer keeps the loading veil up waiting for themes.json. The default-theme step asked for that file although such a profile has none; the request could only fail, and it held the veil for one to two seconds. The step now skips it.

[3.10.5] - 2026-09-26 ​

Fixed ​

  • The mobile toolbar keeps its filter button whatever fixed buttons are turned off. The filter group was inserted at the seventh fixed button, but only four are always there: with geolocation, the theme selector or the layer manager turned off, the bar stopped at six and the filter button was never created — no error, while the filter panel was on. The group now sits immediately before the layers button, or after the last fixed button when there is none. A bar with every fixed button on keeps its layout.

[3.10.4] - 2026-09-26 ​

Fixed ​

  • The filter now reaches line layers. A text search, a category or any other field without layers filtered points and polygons and left every line layer drawn: the kernel skipped line layers unless they declared search.enabled: true, a key the profile schema no longer accepts. Line layers are now filtered like every other geometry. To keep a layer out of a field, list the layers that field targets in its layers. GeoLeaf.Layers.setVisibleSubset() had the same hole: it did nothing on a line layer, and now does. A leftover search block on a layer has no effect.
  • The filter panel filters every loaded layer in one pass. It used to run three passes, one per geometry family, and a layer whose kind fitted none of them (mixed, or unknown for a layer created empty and filled later) was never filtered. geoleaf:filter:apply is therefore emitted once per application instead of three times, with the total activeCount. GeoLeaf.GeoJSON.filterFeatures(fn, { geometryType }) folds every spelling a profile may declare into its family: a polyline, multiline or LineString layer joins "line", a multipolygon or fill-extrusion layer joins "polygon".
  • A vector-tile layer draws the labels it declares. The label layer was added on the layer's vector source without a source-layer. MapLibre refuses such a layer and reports it as an error event on the map: the label toggle read "on" while nothing was drawn. It now names the source-layer the layer's own sub-layers draw. A vector-tile layer created through GeoLeaf.Layers.create() also arms its declared labels now, as a GeoJSON layer does.

Documentation ​

  • A vector-tile layer is not filtered by the filter panel, nor by setVisibleSubset(): the predicate judges the features in memory, and a vector-tile layer keeps none. This is now written where the switch to tiles is decided.

[3.10.3] - 2026-09-25 ​

Fixed ​

  • Documentation that contradicted the code, corrected against it.
    • GeoLeaf.Storage.clearAll() promised to wipe the sync_queue store, which no longer exists. It wipes the cached profiles plus preferences and metadata, and never touches the field captures (features, outbox).
    • GeoLeaf.init()'s contract taught a data key. It reads map (target, center, zoom, mapOptions) and ui.theme, or the flat shorthand, and ignores the rest; the profile comes from the configuration boot() loads.
    • The sync contract said a write ending on 401 or 403 ends the session: only 401 does. A 403 is a missing right, which no sign-in lifts.
    • The requeueable quarantine motives are five, not three: authRequired and dialectNotSupported were missing from requeueQuarantined's documentation.
    • A replay counter documented as surviving a requeue is reset by it.
    • ServerDeletionPolicy promised that an entity deleted on the server leaves the map. It stays on the device, with the operator's work, until the operator destroys the capture.
  • Destroying a quarantined capture no longer leaves its entity on the device for good. The quarantine lives on the queue entry, so Storage.discardQuarantined() removed that entry and left the local record claiming local work that no entry would push and that the pull never replaces. A layer reading the device before the network drew it on every load, online too: an entity the server deleted, a creation it refused, or an edit it refused in place of its own version. The record now returns to the server's truth. It is removed when the server has nothing to give back (a creation that never landed, deletedOnServer). Otherwise it is marked synced, so the next pull replaces it. A record another queue entry still names is left untouched.
  • Two dead CSS rules removed from the feature-info side panel. They meant to shift MapLibre's bottom-right controls when the panel opens. MapLibre's stylesheet is unlayered and overrode them, and the controls already follow the map container, which the panel shifts. Nothing changes on screen.
  • @geoleaf-plugins/geocoding 1.1.1 — its stylesheet source said a 3rem inset left room for MapLibre zoom controls, which GeoLeaf does not mount. The comment is corrected; the package ships its sources, hence the version. No runtime change.

[3.10.2] - 2026-09-25 ​

Fixed ​

  • A layers.json entry's label overrides its layer config's, as the schema promised. With a configFile, the loader built the layer from that file plus the entry's layerManagerId, and dropped the entry's label. The override now reaches the layer, on the bundle path and on the HTTP cascade alike. An entry without configFile is the layer itself, and its label was always read.
  • layers.schema.json no longer accepts order or defaultVisible on an entry. No code read either: a layer manager section lists its layers by descending zIndex, so that the list mirrors the stacking on the map, and visibility at load follows the theme. A profile carrying them is now refused by the schema instead of being silently ignored. The schema is not part of the npm package, and a running map is unaffected.

[3.10.1] - 2026-09-25 ​

Fixed ​

  • Loading the bundle no longer writes <html lang>, and no label is frozen before the boot. Two defaults were translated when their module was imported: the layer manager's title and the branding's default text. The first of them ran the page's first i18n resolution, before any host or profile had configured anything. That resolution froze both strings in French, whatever the profile's ui.language or labels. It also published <html lang> while ui.syncDocumentLang still read its default, so an embedding host could not keep the page's attribute from being rewritten. Both defaults are now resolved when their control is built. Before the boot, a label follows the live configuration and writes nothing. The boot alone fixes the language and publishes the attribute, reading ui.syncDocumentLang at that moment.
  • GeoLeaf.Config.set keeps a value set before anything has read the configuration. set had no store guard, unlike get, getAll and getModuleConfig, and dropped such a value with a warning. This was hidden until now because the label resolved at import happened to read the configuration first. The TSDoc of Config.set and of boot()'s beforeBoot now states when such a value survives the boot. The application configuration is merged over it, and a modular profile replaces each top-level section it carries (ui among them). A host overriding a profile value, such as ui.language, does it in beforeBoot.

[3.10.0] - 2026-09-25 ​

Added ​

  • IMapAdapter.declareOwnedStyleIds(owner, ids) — engine layers placed outside the adapter can cross a basemap switch. A basemap switch that replaces the map's style carries into the incoming style what the adapter owns; a layer added to the engine directly — a plugin's own, or one an integrator adds through getNativeMap() — was erased by it. Declared through this member ({ layerIds, sourceIds }, keyed by owner; null withdraws), it is carried like the adapter's own, in place, with its data. Declarations are read when the switch runs and die with the map. The type of the ids, DeclaredStyleIds, comes with IMapAdapter's other types from @geoleaf/core/contracts/map-adapter.contract.js. The member is optional on the contract: an adapter without it carries nothing it did not create.

  • GeoLeaf.Layers.search(query, opts?) — find a feature by its reference, off-network. A layer declaring searchable: { fields } is searched on each feature's id and on those properties, accents, case and word order ignored — the text filter's own rule, now shared. Best first: exact id, exact value, id or value starting with the query, then every word found. It reads what the map holds, so it works with no network for a layer read from the device store; a layer never loaded and a vector-tile layer are not found. A result (FeatureMatch, exported with the contract's types) carries label, lat, lng and bounds — the shape of an address result — plus layerId, layerLabel and featureId.

  • GeoLeaf.Layers.focus(layerId, id, opts?) — frame a feature and select it. A point is flown to (zoom 17 unless opts.zoom), a line or a polygon fitted; its selected feature-state is set, and the previous selection, on whatever layer, cleared: one per map. A feature without properties.id is framed, not selected.

  • The selected feature-state is painted. Declared by LayerFeatureState as a "selection halo" since the contract existed, it was read by no shipped paint: a point's circle stroke and a line's or a polygon outline's stroke now show it, at creation and after every re-style.

  • searchable in a layer's configuration, validated by the profile schema: at least one field name. The key is not search — that former block stays out of the schema.

  • @geoleaf-plugins/geocoding 1.1.0 — the search box finds the map's own features, off-network first. modules.geocoding.provider takes an ordered list, and the name "layers" asks GeoLeaf.Layers.search: ["layers", "nominatim"] lists features before addresses, and while the browser is offline the address service is not asked at all. Selecting a feature focuses it. Providers resolve through a registry — GeoLeaf.Geocoding.registerProvider(name, factory) adds one. A search that finds nothing now says "No results". Requires core 3.10.0 for "layers"; with an older core it answers nothing.

  • GeoLeaf.Storage.resolveOptions(url) — a dropdown's choices, kept for off-network use. A field loading its choices from a URL (fetchOptions) had no copy of them the device keeps: off the network it offered its placeholder alone. The offline preparation (CacheManager.cacheProfile) now fetches every list the chosen layers declare — on the field or on its edit block — and keeps them in the preferences store, which the cache budget never evicts; any list resolved online is kept too. A held list is answered at once, and refreshed behind the answer while online. It answers null when nothing can: the caller then fetches itself.

  • @geoleaf/field-renderer 1.3.0 — setOptionsResolver(fn). A dropdown asks its host for a fetchOptions list before the network; with no answer it fetches as before. The value an entity holds is always offered and selected, even when the list received no longer carries it, and a static options list declared beside fetchOptions is the fallback when the list cannot load.

  • @geoleaf-plugins/editor — its forms read the kept lists, through setOptionsResolver wired to GeoLeaf.Storage.resolveOptions. With an older core, a dropdown fetches as before.

  • GeoLeaf.Storage.preflight() — "can I leave?", in one read. Before going off-network, the facts a device must be read for, assembled: whether the browser keeps this origin's data (navigator.storage.persisted(), which the PWA requested at boot and only logged — now read, as a StoragePersistenceRegime), the quota, each layer that declares a pull source and whether it is on the device, the write queue, and the last offline preparation. A verdict says what they mean together: notReady, degraded or ready. Never throws; null with no engine wired. The write session is deliberately not in it: its validity is the connector's knowledge.

  • The offline preparation records what it leaves out. A zoom level skipped because it held too many tiles, or an enumeration stopped at the total tile cap, was only logged; it is now kept in the cache manifest (TilePreparationTrace, exported with the sync contract's types), with the download zone asked for.

  • @geoleaf-plugins/offline-ui 1.6.0 — the offline window answers "can I leave?" Next to the download: the core's verdict, the persistence regime, each sourced layer flagged when it is not on the device, the zooms a preparation left out. Re-read after a download, a purge, and when the queue or the network moves. Requires core 3.10.0; hidden with an older core.

Deprecated ​

  • @geoleaf-plugins/geocoding — an unknown single provider value will be refused. A typo or an http:// URL has always fallen back, silently, on the default address service — sending the user's input to it. From 1.1.0 the fallback is kept but NAMED in a warning; from the plugin's next minor version, the value will be refused and nothing asked for it. Inside the new list form, an unknown name is refused already.

Fixed ​

  • Labels survive a basemap switch that replaces the style, and a vector default basemap at boot. A label layer is added to the engine directly, on its layer's source: a switch to or from a vector basemap carried the source and erased the label layer, and the capability, still believing the labels shown, never rebuilt them — zooming did not either. The same happened at boot with a vector default basemap, to every label built before its style arrived. The labels capability now rebuilds its label layers whenever the style is replaced, and reads their font from the incoming style, so that it is one the new glyph server serves. Labels the user switched off stay off. Raster-to-raster switches, which never replace the style, were not affected.
  • @geoleaf-plugins/measure 1.0.6 — measures survive a basemap switch. A switch to or from a vector basemap erased the plugin's sources and layers, and every finished measure with them; they are now declared, and carried. Requires core 3.10.0; with an older core, the plugin behaves as before.
  • @geoleaf-plugins/cog 1.0.4 — a COG layer survives a basemap switch. Same defect, same remedy: the image source and raster layer are declared to the adapter driving the map passed to GeoLeaf.COG.addLayer, and withdrawn by remove(). Requires core 3.10.0.
  • @geoleaf-plugins/editor 1.5.1 — drawing after a basemap switch no longer throws. The drawing engine's layers were erased by a switch to or from a vector basemap, and its next render threw an uncaught TypeError (Cannot read properties of undefined (reading 'setData')) — on the next click, or on a theme change. The layers it places when a tool is first armed are now declared, and carried. Requires core 3.10.0.
  • @geoleaf-plugins/editor — a saved move or a deletion reaches the layer's data, not only the map. Saving an edited geometry or deleting a feature rewrote the map source directly and left the core's layer store as it was: the store is what GeoLeaf.Layers, the filter and the table read, so a search recentred on a moved feature's old position and found a deleted one again. The same path rewrote the source from what it held, which under an active filter is a subset — the save froze that subset as the layer's data. Both now go through the store's own unit mutations. A feature CREATED in the session still reaches the store at the next load only.
  • The sync report no longer flags, for ever, a layer that has nothing to pull. It judged a layer "declared offline" on offline.enabled, while the pull requires offline.source.url: a layer read from the local store with no source was reported declaredNeverPulled on every device, permanently — nothing could ever pull it. Such a layer is now notDeclared; the report and the pull share one predicate.
  • getCacheStatus().cachedAt is a date. It read a manifest field nothing wrote, and was always null; the manifest now writes it, and an older manifest falls back on its generatedAt.
  • @geoleaf/field-renderer — a read-only dropdown stays read-only when its list fails to load. The failure path kept the loading <select> and re-enabled it, so a field meant to be read-only became editable; and the value the entity held matched no option, and vanished from view.

[3.9.0] - 2026-09-24 ​

Added ​

  • A layer can stay out of the layer manager: showInLayerManager: false. Declared in a layer's config file or passed to GeoLeaf.Layers.create, it removes the layer's row from the panel — and nothing else: the layer still loads, follows its theme's visibility, keeps its legend and stays drivable through GeoLeaf.GeoJSON.showLayer / hideLayer. It holds across theme switches, which re-list every configured layer. A section left with no listed layer is not created, unless layerManagerConfig.sections declares it (its title then shows with no rows). Absent or true lists the layer, as before.
  • @geoleaf-plugins/editor 1.5.0 — GeoLeaf.Editor.discardDraft(featureId?) abandons the draft awaiting its form. A shape just drawn, or a point captured through AddForm: the form closes without its unsaved-changes confirmation, the geometry and its undo/redo entries go, a placement marker is retired — nothing is persisted, no event is emitted. It can be called from a geoleaf:editor:feature-created listener, and the form then never opens: a host that persists new features by its own means no longer has to simulate a click on the form's cancel button. It returns false when no draft is pending, when the draft is not featureId, or while its form is saving.

Fixed ​

  • Core.destroy() during the boot no longer fails the boot. A host unmounting right after mounting destroyed the map while the boot was still running: the remaining modules failed on it, geoleaf:boot:failed was emitted, and the failure screen stayed up over the host's page — over a recreated map too. The boot now ends the way a refused beforeBoot does: geoleaf:boot:aborted with reason: "destroyed", the loading veil hidden, no failure screen.
  • Core.destroy() right after geoleaf:app:ready no longer throws. The legend's debounced rebuild (150 ms) survived the destroy and mounted its control on the destroyed map — "MaplibreAdapter: map is not ready", uncaught. Core.destroy() now tears the legend down; its teardown was only reachable through the module registry, which the integrator's path never runs.
  • A vector basemap applied at boot no longer erases the layers created while its style downloads. With a vector default basemap whose style is a URL, any layer created after the switch began and before the style arrived — through GeoLeaf.Layers.create, for instance — was erased when the style landed: its source and sub-layers were gone while the kernel still reported the layer present, so GeoJSON.updateLayerData wrote into nothing. MapLibre runs the transformStyle GeoLeaf passes to setStyle only once a style URL has downloaded, and the layers it carries over were read when the switch started. They are now read when the style arrives. A raster default basemap was never affected, and a switch made after the boot behaves as before. An adapter implementing IMapAdapter.buildStyleChangeTransform must likewise read what it owns when the callback runs; the contract no longer describes a null return for an adapter that owns nothing yet.
  • The layer manager lands in the side panel's layers tab even when it is built after the panel is activated. The desktop side panel adopted the filter panel and the legend whenever they appeared, but moved the layer manager only if it already existed: a host calling GeoLeaf.LayerManager.init() after GeoLeaf.UI.activateDesktopPanel() got a layer manager outside its tab. It is now adopted as soon as it appears, in either order, and given back when the panel deactivates. The boot, which builds the layer manager first, was not affected.
  • @geoleaf-plugins/measure 1.0.5 — a measure finished just before a reload was lost. Measures are saved to localStorage after a ~300 ms debounce, and a save still waiting for its timer when the page unloaded never ran. A pending save is now written at once when the page is hidden or unloaded (pagehide, or visibilitychange to hidden); the debounce is unchanged otherwise.
  • @geoleaf-plugins/editor 1.5.0 — destroying the editor with its form open threw. The form's cancel removes its shape through the drawing engine, and the engine was stopped first: the destroy threw "Terra Draw is not enabled" and the rest of the teardown never ran. The form is now closed first.
  • @geoleaf-plugins/editor 1.5.0 — cancelling a form after an undo threw, and dropped the wrong history entry. An undo reaching the stack while the form was open removed the drawn shape; the cancel then asked the drawing engine to remove it again (which throws), and dropped whatever entry topped the stack. The cancel now removes only a shape the engine still holds, and only that shape's entries, from both the undo and redo stacks.

[3.8.0] - 2026-09-23 ​

Added ​

  • GeoLeaf.Filter.getActiveFilter() says under which category each checked sub-category is checked. A taxonomy field of the returned state now carries subValues beside values: one { value, category } per checked sub-category checkbox, in panel order — value the sub-category id, category the id of the category it is listed under. The key is absent when no sub-category is checked. values does not change: the checked categories, then the checked sub-categories, flat — the map is still filtered on it, and a permalink still stores only it. A sub-category id is unique only within its category, and the flat list could tell neither a checked category from a checked sub-category, nor, for an id listed under two categories, which one was checked. Nothing in subValues is deduplicated, so the checked categories are values minus subValues[].value, counted with multiplicity. applyFilter(state) reads the key back: a shared id is restored under the category it was checked under, where both used to be checked. Entries that are not { value, category } strings are ignored. A state without subValues — saved by an earlier version, or restored from a permalink — restores exactly as before. The entry type is exported as TaxonomySubValue.

Fixed ​

  • @geoleaf-plugins/editor 1.4.2 — pressing a selected shape near an edge moves the shape, instead of inserting a vertex. Terra Draw resolves a drag on a selected shape in a fixed order — resize, vertex, edge midpoint, then the whole shape — each within a capture radius the editor did not set for this mode, hence Terra Draw's default of 40 px, eight times the drawn midpoint dot. A press 35-38 px from an edge's midpoint therefore inserted a vertex there instead of moving the shape — about a third of a 122-vertex polygon's fill at zoom 6, and more on a shape small on screen. The select mode now reaches 12 px, the drawing modes' radius: within it, a vertex is dragged or a vertex is inserted as before; anywhere else, the whole shape moves.

[3.7.0] - 2026-09-23 ​

Added ​

  • GeoLeaf.GeoJSON.setWorkerUrl(url) — a host sets the URL of the GeoJSON worker. By default the worker is geojson-worker.js, resolved once, when the bundle loads: next to the last <script src> whose URL names a geoleaf…js file, without its query string — and against the PAGE when there is none, which is the case of a bundle imported from an inline module script with no plugin loaded by <script src>. A host loading the bundle that way had no way to point at the worker, and a host serving its files under a content token (?v=…) with a long cache lifetime had no way to version its URL: a new release could run against a cached worker from the previous one, a postMessage protocol mismatch that nothing reports. The URL set is read at every worker construction, so it may be set after the bundle has loaded — before GeoLeaf.boot() to cover the first worker. null restores the default; any other value throws a TypeError. Setting a URL also clears an earlier worker failure, which used to send every later load to the main thread for the rest of the session. Without a call, nothing changes.

  • A layer's labels block is read — labels can be declared on the layer itself. The block takes the same object, with the same meaning, as a style file's label. A style that carries its own label object keeps priority; the layer's block applies otherwise, including when the default style file is missing. It was half-wired: labels.enabled made the loader initialise the labels, and every reader after it looked at the style alone — so a layer whose style file 404'd showed no labels, with its labels button greyed out. The key is now declared in layer-config.schema.json and typed on LayerDefinition, so Layers.create() takes it too. visibleByDefault defaults to false, as in a style file.

Fixed ​

  • A GeoJSON worker that fails no longer fails the loads it was built for. When the worker's script did not load — a 404 fires its error only once the first loads are pending — or when it crashed, the loads in flight were rejected, although the handler meant to send them to the main thread: the layers of the first load of a page rendered nothing, and only the later ones fell back. They are now replayed on the main thread, unless some of their chunks already reached an onChunk callback — replaying would deliver those features twice, so that request is still rejected. And the failure of a worker built from a previous URL no longer sends the later loads to the main thread when a host has set another URL since: that URL is tried at the next load, and the error log names the URL that actually failed.

  • Three buttons read a colour token no theme defines. The boot-failure screen's primary button and the popup's action buttons took their text colour from --gl-color-on-accent, which is set nowhere: they always fell back to white — 1.46:1 on the light theme's peach accent, about 2.8:1 on the default orange. They now fall back to --gl-color-accent-contrast, the token every theme sets for text on the accent. A host that defines --gl-color-on-accent keeps its value.

  • @geoleaf-plugins/editor 1.4.1 — the drawing engine is really loaded on the first tool use. Its source already imported Terra Draw with import(), but the build's chunking rule also captured the plugin's own adapter module, which the entry imports statically: the whole engine chunk left with the plugin, on every page that loaded it — about 47 KB gz before anything was drawn. The rule now captures the vendor packages only, and the plugin's entry no longer imports the engine but through import(). Because the engine is now fetched when first needed, that fetch can fail — offline, or after a redeploy: the failure is reported in the console and the armed tool is disarmed. The plugin does not keep the failure: the next tool use asks for the engine again. Whether the browser fetches it again is not the plugin's to decide — some keep a failed module fetch until the page reloads — and after a redeploy the old engine chunk no longer exists: in both cases, only a reload recovers.

  • @geoleaf-plugins/connector 1.3.2 — http:// is tolerated on every development host, not only localhost and 127.0.0.1. The rule keeps the token out of cleartext in production, and it refused any other name: a development instance served over HTTP under *.test threw at configure(), so the map started without data. The tolerated names are now exactly those no production host can carry — localhost, *.localhost, the loopback addresses (127.0.0.0/8, ::1), which a browser itself treats as secure contexts, and names under .test, reserved for testing (RFC 6761). .local stays refused: it names real intranets. The page's host is judged, as before.

  • The connector's documentation described options that do not exist. The plugin configuration guide documented auth.type ("bearer" / "apikey" / "cookie"), auth.token and auth.headerName: none exists, and such a configuration fails. There are two modes, getToken and auth, and the connector's README now points to what the server must answer (§3 of the server contract) and to the way out for a server speaking another dialect (§7.6 of the security guide). It also said the core is a dependency: it is a peer dependency.

  • The label documentation taught options that were never read. enableLabels() examples used property, template, direction and styleFile — the first three are not read, the last one is refused — and a label offset.angleDeg and a background block that no schema accepts; its label object also wrote field as "properties.nom", which displays no text — a field names the feature property as it is. The labels page also described the module of an earlier major version (permanent tooltips, CSS style files, planned features); it now describes the MapLibre symbol layer the labels are, and when toggleLabels() and the layer manager's button act. The TSDoc of enableLabels() also said showImmediately defaults to false (it is true) and that the call is only asynchronous with a styleFile (it always returns a promise).

[3.6.1] - 2026-09-22 ​

Fixed ​

  • The default basemap could wait for the user's first gesture — and then tilt the camera into it. When setBaseLayer ran before the style had finished loading, it deferred the activation onto the map's one-shot idle event. idle fires only once the map is FULLY settled — style, sources, tiles, no transition in flight — which a rich profile may never reach: measured on a 43-layer profile with third-party tiles, no idle occurred at boot at all, and the first one was provoked ~20 s later by the user's own first gesture. The basemap was applied at that moment, and a basemap declaring terrain.default3D then ran map.easeTo({ pitch: 60 }) — tilting the map mid-gesture and losing it. The deferral now re-tests isStyleLoaded() on styledata, sourcedata AND idle, and detaches from all three as soon as it applies. The three are needed together: isStyleLoaded() covers the style and its sources, and measurement shows the last styledata can land before the predicate flips — the flip is then carried by a sourcedata, with idle left as a net. A deferral superseded by a later basemap choice still stands down, and it now unsubscribes when it does.

  • A 3D basemap stayed flat: the tilt was animated, and the animation was cut. A basemap declaring terrain.default3D posted setTerrain() and then easeTo({ pitch }). easeTo is cancelled by ANY later camera command, and the application posts one when it reveals the app — it re-frames the profile bounds shortly after the loading veil lifts. Whichever landed last won, and the order was a race: measured on a plain boot with nothing acting on the page, a basemap asking for pitch: 60 reached it ONCE in six runs and stopped at 6–9° the other five. The "Relief 3D" basemap the user had selected therefore rendered essentially flat, with the terrain active and nothing logged. The tilt is now posted with jumpTo, which has no animation to cut; at boot it happens behind the veil, so there is no visible jump. Measured after the fix: 6 runs out of 6 at the requested angle.

  • Re-framing the map no longer redresses the camera. fitBounds computes a camera from scratch and, given no pitch/bearing, applies zero — so any re-framing silently flattened a tilted map. The adapter now carries the current angle over, which makes a re-framing answer the question it is asked: what is on screen, never from which angle. ⚠️ init() is deliberately unaffected — it establishes the first camera, where zero is right.

[3.6.0] - 2026-09-20 ​

Added ​

  • ui.syncDocumentLang — a host can keep its own <html lang>. initI18n() published the resolved language to the document element unconditionally, the only guard being the absence of document. That attribute is PAGE-wide and a map does not always own the page: embedded in a host that sets it for its own session, a map mounted in a form rewrote the locale of everything around it. Defaults to true, so a standalone application is unaffected, and opting out suppresses only the write — ?lang=, the stored preference and ui.language still resolve exactly as before, labels included. The guard sits inside _syncDocumentLang() rather than at the boot call site, because getLabel() re-enters initI18n() when a label is resolved before boot and a guard placed around the boot call would leave that path writing.

  • Layers.getVisibilitySource(id) — which authority set a layer's visibility. Returns "user", "theme", "zoom", "system", or null for an unknown layer. isUserOverridden only answers "not the user", which leaves a theme indistinguishable from a zoom threshold, and a caller reconstructing the value from it writes the same thing for both. The same value already leaves the core on every change through the typed geoleaf:layer:toggle event; this is the PULL route, for code that mounted after the change — a panel rebuilt on navigation, a host re-attaching to a live map. ⚠️ The recorded source lags a REFUSED write: a change blocked because a higher-ranking source holds the layer leaves the previous source in place. Read it as "who decided what is displayed", not "who asked last".

  • Layers.getStyle(id) — which style a layer wears, as { id, label }. A normalised projection, and the narrowness is the contract: the runtime entry's currentStyle has two shapes depending on which writer spoke last — the flattened paint handed to the adapter, which carries no id and no label, or the full style document the style selector restores over it. Reading that field directly gives one or the other with no way to tell, a defect already shipped once where the labels toggle greyed out for good. Both fields are string | null and always present; label is null when the applied style carries no display name, including when the underlying field holds a map-label config object rather than a name. The paint (style, styleRules) is deliberately not exposed — it is precisely the unstable part, and published subpaths are added, never removed.

Changed ​

  • @geoleaf-plugins/table 1.1.0 — the table searches and selects on its MODEL, not on the rendered rows. Past 150 rows the table renders only a window, while search and selection read the DOM: on a 30 000-row layer "select all" took 33 rows, and a search evaporated on the first scroll. Both now read the model the plugin already held. A range selection starts from a real anchor — the last single click — instead of the last entry of a Set, and it no longer no-ops when the anchor has scrolled out of view. The header checkbox compares the selection to the visible count rather than to the rendered one. Under an active search, "select all" takes the result, and a n / total counter says the view is a subset.

  • A layer's table.searchFields is read. It was accepted by the layer schema and read by nobody, while the configuration guide promised that "only these properties are interrogated". Both published shapes are accepted — ["properties.name"] and ["name"] — so neither document is left false. Absent or empty still means every column.

  • maxRowsPerLayer defaults to 30000, and the cut now warns the user, once per layer and per session, naming both the kept count and the real total. Its only channel was a console.warn: a table reduced to its first rows looks exactly like a complete one. A declared 0 also means zero — || silently replaced it with the default.

Deprecated ​

  • modules.table.virtualScrolling is inert and marked. Virtualisation is decided by a row-count threshold, never by this key; profiles/_reference declared it false while the virtual renderer kept running. It left the defaults and the shipped profiles but stays in the published TableConfig type, marked, because deleting a member of a published type breaks the compilation of an integrator who wrote it. Same treatment as its twin pageSize, for the same written reason. Neither has a replacement, so neither enters DEPRECATIONS.json.

Fixed ​

  • A table row's identity no longer drifts as you scroll. The id written to data-feature-id came from a module counter reset only by a full render, while the virtual scroller re-created rows without that reset. After a scroll a row displaying one entity carried the id of another — a key of the lookup map, but the wrong one, so selecting that row selected a different feature and zoom flew elsewhere. Identity is now resolved once per load and travels with the row.

  • A sort no longer desynchronises the selection from the data. The id→index map was built before the sort reordered the features in place, so every entry pointed at a pre-sort position: zoom, highlight and export-of-selection all handed back different features than the user had selected. Measured: 300 desynchronised entries out of 300. Order and index are now derived in one pass.

  • The table search input is unbound on teardown. Its listener was registered with a raw addEventListener, so destroy() left it — and its pending timer — attached to a detached input.

  • A layer a pull emptied no longer falls back to the display source. The local store answered null when it held nothing, and the loader read that as "never pulled": it fetched data.* instead — a static display file, possibly months old — and presented it as the user's own entities, silently. The case was rare while a pull only ever wrote; the convergence of 3.5.0 made it ordinary, a complete pull now removing what it did not return. An empty store left by a COMPLETE pull now answers an empty collection, and the layer is displayed empty. A pull that failed or was cut still hands the read back to the network: its store is empty because the run stopped, not because the source is.

[3.5.0] - 2026-09-19 ​

Added ​

  • The download zone bounds the entity pull. The zone drawn in the download window (@geoleaf-plugins/offline-ui) was persisted with the selection and reached CacheManager.cacheProfile() whole, but it only bounded the vector tiles: each layer declaring offline.source was still pulled as its whole collection, capped by offline.maxFeatures alone. Its extent now leaves with every pull as the OGC bbox parameter, [west, south, east, north]. Without a zone — or with one whose bounds are not finite numbers — nothing is guessed and the pull covers the whole collection, as before.

  • A second pull rewrites only what changed, and says so: unchanged on the pull report. The freshness marker read from offline.source.versionProperty was stored on every record from the first pull on — for the write cycle's conflict filter — and never compared by the pull itself: every synchronised record was rewritten on every pull, and counted written. A record the source serves under the marker already stored is now left as it is, and counted in the new unchanged field of GeoLeaf.Storage.pullLayer()'s report; written counts only real writes. A row served without its marker is always rewritten — nothing says it did not change.

  • An entity deleted on the server leaves the device: removed on the pull report. The pull only ever wrote. An entity the source had stopped serving stayed in the local store forever — still drawn on the offline map, with no row left for any edit of it to reach. A COMPLETE pull (neither cut by maxFeatures, nor aborted, nor failed) now removes the synchronised records carrying a server identity that its answer did not return, and counts them in the new removed field. Local work is never removed — an edited entity the server deleted is left to the write queue, which sets it aside as deletedOnServer — and neither is a record without a server identity. A pull that is cut, aborted or failed removes nothing.

  • Freshness and deletions declared per layer: offline.source.delta. A layer whose source serves changes and deletions declares both, together — { "freshness": "datetime", "deletedProperty": "<property>" }, the schema refusing either half alone, since a freshness filter never sees a deletion. Its first pull is complete; the next ones, for the same source and extent, ask only for datetime=<mark>/.. — the OGC API Features Part 1 temporal filter, the mark being the greatest freshness marker already served — and report mode: "delta". A served entity whose declared property is set is a tombstone: never written, its synchronised copy leaves the device. A delta removes nothing else. The mark is dropped — and the next pull is complete again — by a purge of the local cache, a change of zone or source, a delta cut by maxFeatures, or a marker that is not a readable instant. Without the declaration every pull stays complete, and converges as described above. streamOgcApiFeatures() gains the optional datetime argument this needs.

    ⚠️ No server of this repository speaks it yet. The mechanism is proven against a source with state in the unit suite and on the shipped bundle, never against a real server; what a server must guarantee for it to hold is written in the server contract page.

  • pullLayer() reports mode — "full", "delta", or null when refused before any request.

Changed ​

  • A complete pull defines the layer's synchronised content — a bounded one included. With a bbox (the download zone, or pullLayer(id, { bbox })), the answer is what the source holds for that extent, and what lies outside it leaves the device too: successive pulls of different zones no longer accumulate, the last complete one wins. Decided with the convergence rule above; a caller that pulled several zones in turn to build a union must pull their enclosing extent.

Fixed ​

  • A photo written back after the fact no longer erases the entity that owns it. A photo taken off-network is uploaded when the network returns, and the editor then writes its URL onto the entity with an edit that carries that one attribute. The local store took such an edit as the WHOLE entity: the record kept the URL and lost its position and every other attribute — the point vanished from the offline map. While the create was still queued, the two coalesced and the create would have left with nothing but the URL and the client identity. A partial edit is now MERGED over the stored entity: its properties override the stored ones key by key, and the stored geometry is kept when the edit carries none. Observed on the shipped bundle before the fix, in the order the field takes — the photo is uploaded before the queue drains —: the create left with the URL and the client identity alone.

    ⚠️ What GeoLeaf.Storage.applyEdit() now does with a partial entity is the rule the store already announced ("the store keeps what the edit does not bring"). Omitting an attribute no longer removes it from the local record — it never removed it from the server row either, which a PATCH leaves untouched. To clear an attribute, send it with null.

  • A cap that lands exactly on a page boundary is reported as a cut. The OGC API Features walk stops as soon as it holds maxFeatures entities, before asking for the next page — and it flagged the result as truncated only when it held MORE than the cap. When the cap fell exactly on a page end, the walk stopped on a page that announced more and reported itself complete: the "N out of M" notice stayed silent on the display path, and the offline pull reported capped: false for a subset. The page the walk stops on is now read for a next page (the next relation, or the declared cursorPath), without following it.

  • « Stop » now stops the entity pull too. CacheManager.cancelDownload() only reached the resource downloader, whose controller exists while the resources download and no longer. A stop pressed while the entities of the layers declaring offline.source were being pulled reached nothing: the panel said the download had stopped while the pull ran to its end. And a complete pull removes what it did not return, so the zone just drawn still replaced the previous one. A stop pressed while the resources were still being enumerated was lost the same way, and every resource was downloaded. Each download now has its own controller, and cancelDownload() raises it:

    • a pull under way ends at its page, keeps what it wrote and removes nothing;
    • after a stop, no further layer is pulled;
    • a stop pressed before the resources start means nothing is downloaded, and the manifest an earlier download left is not overwritten.

    The result of cacheProfile() says cancelled: true.

  • @geoleaf-plugins/offline-ui 1.5.1 — "profile area" offers the profile's extent, not the current view. The download window's zone selector asked the map for its getMaxBounds(), and the map it receives — the GeoLeaf map adapter — has no such method: the button fell back to getBounds(), the current view, and the profile's extent was never proposed. It now reads the extent the profile declares, map.bounds, unpadded; getMaxBounds() and then the view remain the fallbacks when a profile declares none, or declares one that is malformed.

  • @geoleaf-plugins/offline-ui 1.5.1 — the download zone is no longer lost to the layer list. The window keeps its selection in one record, written by two paths that did not wait for each other: the layer list reads it when its save starts and writes the whole record back after its DOM work and its estimates, so a zone persisted in between was overwritten — and the window itself starts one of those saves when a profile has no selection yet. The download then left with no extent at all: the whole collection pulled, the whole map tiled. Both paths now go through one serialised read-modify-write, each re-reading what the previous one wrote. It serialises this tab only: two tabs on one profile can still overwrite each other, as before.

  • @geoleaf-plugins/offline-ui 1.5.1 — the confirmation of « Stop » is reachable with a mouse. The window sat above the layer the confirmation dialogs use, so the confirmations it opens — « Stop », « Clear cache » — opened UNDERNEATH it: their « Confirm » was covered by the window's own rows, and only a keyboard could reach it, blind. The window now shares that layer, and the dialogs it opens stack above it.

  • @geoleaf-plugins/offline-ui 1.5.1 — a stopped download is no longer announced as a success. Once « Stop » had shown "stopped" and handed the button back, the download window still announced a success when cacheProfile() resolved: the bar at 100 %, the ✅ count, a success notice. It now reads the cancelled the result carries, and then only re-reads the status and the icons of the cached layers.


[3.4.0] - 2026-09-19 ​

Added ​

  • A settled conflict now KEEPS the version it crushed. lastWriteWins is the policy and it is unchanged — what the device holds wins, a deletion included. What changed is that the drain reads the server's row before overwriting it, stores it in a new conflicts object store, and emits geoleaf:offline:write-conflict naming the entry. Until now the overwrite was blind: after a settled update the server still held a row, but after a settled delete it held nothing and neither did anything else — the only irrecoverable loss of the write cycle.

    ⚠️ The re-read never decides whether the write happens. A server granting UPDATE without SELECT is an ordinary permission split; making the overwrite conditional on the read would turn every conflict into a spent budget and then a quarantine. When the read cannot conclude, the record is stored with readOutcome: "unreadable" and the overwrite proceeds — the gap is recorded rather than invented.

    ⚠️ The local database moves to version 6. The upgrade is additive and was proven against a real v5 base: the seven existing stores, their indexes and the outbox's autoIncrement generator all survive it. No integrator action is required.

  • Storage.listConflicts(layerId?) and Storage.clearConflicts(layerId?) — the archive is readable and purgeable. One record per entity, keyed [layerId, localId]: a second conflict on the same entity replaces the first. That key is also the store's bound, since nothing evicts it. Storage.getStats() gains a conflicts.count tally for the same reason.

  • Storage.requeueableReasons() — which quarantine motives an operator's gesture can lift. An interface offering "retry all (motive X)" has to know which motives that sentence is true for before the click; without this read every surface would hard-code the list, and a plugin cannot import the core.

  • The GeoJSON worker's header hook may answer with a promise. A plugin authenticates the worker's requests through globalThis.__GEOLEAF_WORKER_HEADERS_HOOK__, which the core read synchronously, right before posting to the worker: a token provider that answers asynchronously — an identity SDK — could not reach it, and a GeoJSON layer loaded by URL left without its token. The core now passes { acceptsPromise: true } as the hook's second argument and, when the hook answers with a promise, posts the request once it settles. A synchronous answer is still posted at once; a hook that throws or rejects lets the request go without headers. @geoleaf-plugins/connector 1.3.1 relies on it in getToken mode.

    ⚠️ A hook should answer with a promise only when told. An older core hands the answer straight to postMessage, which cannot clone a promise — the connector answers with one only after reading the capability, and gives no header otherwise.

Fixed ​

  • GeoLeaf no longer unregisters service workers it did not register. With the pwa capability disabled, boot used to call getRegistrations() and unregister every worker on the origin. Mounted inside a host web application, that took down the host's service worker: measured on a real host, which lost its offline mode and its app install with no message on either side. The selection is now made on each registration's scriptURL — only sw-core.js, GeoLeaf's own worker, is unregistered, at boot and at _reset() alike.

    ⚠️ The match is on the last path segment, not on a suffix. A host worker named vendor-sw-core.js is not ours and stays registered. Sub-path deploys are unaffected: the scope is "./", so the origin still has to be enumerated — what changed is which entries are kept.

    ⚠️ Integrators who relied on the sweep to clear an unrelated worker must do it themselves. Nothing in GeoLeaf's own deployments depended on it: the variants ship exactly one worker.

  • @geoleaf-plugins/connector 1.3.1 — a renewal that cannot conclude no longer ends the session. In auth.endpoint mode the renewal answered null for any failure, and the session was erased on it: one minute of an unavailable authentication server signed the device out. Only a refusal ends a session now — a non-2xx other than 408, 429, 500, 502, 503 and 504 (the statuses the write drain waits out), or a 2xx received whole but unusable. A renewal that could not conclude keeps the session: the request gets its 401, and the renewal is tried again when the network returns, when the page comes back to the foreground and when a capture enters the offline queue — a success resumes the queue as before.

    ⚠️ configure() no longer throws, nor opens the login window, for a stored session whose renewal cannot be reached — offline at start-up with an expired token, for instance. It resolves, with or without auth.ui; a ConfigError still names the absence of any session, or a refusal. An integrator without auth.ui who relied on that ConfigError offline now gets a resolved configure(), and a session renewed when the network returns.

  • @geoleaf-plugins/connector 1.3.1 — the host's token reaches every loading path. In getToken mode, GeoJSON layers loaded by the worker and vector tiles left without the host's token: both paths read a store only the auth.endpoint mode fills. They now pull the host's provider at each request — a promise reaches the tiles on MapLibre 5.21 or later, and the worker through the header hook's new capability. PMTiles archives carried a token in NEITHER mode: the pmtiles library reads them through the page's fetch, which the connector excluded for them. They are intercepted now, their Range header kept.

  • @geoleaf-plugins/connector 1.3.1 — a session renewed before the offline engine is wired still resumes the queue. A configure() called before GeoLeaf.boot() renews an expired session while GeoLeaf.Storage has no engine yet: requeueAll answered engineUnavailable, and the capture a previous session set aside under authRequired stayed quarantined — the core's arming pass replays pending and failed entries, never a quarantine. The resume now waits for Storage.whenReady(), lets the core's own arming pass end, then requeues and drains. One wait per page; a core without whenReady gets a single attempt, as before.

  • @geoleaf-plugins/connector 1.3.1 — an instance of createConnector() no longer touches the page's renewal. The token store kept one renewal delegate for the page. An instance built with auth.endpoint installed its own in place of the configure() singleton's — the singleton's session was then renewed against the instance's endpoint — and its destroy() removed the page's delegate whoever had installed it: the singleton's next 401 found nothing to renew with, and the session ended, auth-error included, with no server having refused it. An instance now renews its own reads against its own auth.endpoint and installs nothing the page shares; destroy() only deactivates its reads. Only configure() installs and removes the page's renewal.

  • @geoleaf-plugins/connector 1.3.1 — the queue resume and the renewal relaunch answer their own session only. Both listened to geoleaf:connector:token-refreshed and geoleaf:connector:authenticated whatever API the event named: the renewal of another API's session — an instance of createConnector(), another copy of the plugin — resumed the singleton's offline queue, and disarmed its relaunch while its own session was still waiting for the network. An event naming another baseUrl is now ignored; one naming none still counts.

  • @geoleaf-plugins/connector 1.3.1 — a server that refuses the token it has just renewed no longer makes the offline queue loop. Every 401 renewed with success, token-refreshed resumed the queue, and the next 401 renewed again: as soon as the resume opened a new pass — which a requeue slower than the replay of the refused request makes it do — nothing bounded the renewals. A renewed token the server refuses at once now holds the renewals for one exchange window, as an outage does: the session is kept, the request gets its 401, and the relaunch's moments still retry.

  • @geoleaf-plugins/print 1.3.1 — a printed map asks for its tiles with the live map's headers. The off-screen map the print renders was always created without a request transform: the plugin read a field of MapLibre's request manager that MapLibre does not write, behind a cast that kept the compiler from seeing it. Tiles only a transform authenticates — the vector tiles @geoleaf-plugins/connector authenticates, or an integrator's transformRequest — went to print without their headers, and a protected layer printed empty. The off-screen map now hands each request to the live map's transform, read at request time.

  • A device that went through the offline write cycle can now clear its quarantine.requeueQuarantined and discardQuarantined had existed since 02/08/2026 with no caller in any interface: the only way to use them was to list the queue in a console and paste an identifier. @geoleaf-plugins/editor 1.4.0 wires both into the pending-operations window.

  • dialectNotSupported joins QuarantineReason. A layer declaring write.dialect: "rest" — a dialect the core does not write — used to spend its entries' replay budget against a hole no replay fills, then set them aside under retryBudgetExhausted, which says the server never answered although no request had been made. Such an entry is now set aside at once under its own motive, and Storage.requeueQuarantined accepts it only once the layer has stopped declaring that dialect.

  • An action button's visual weight reaches the DOM. variant on an action field — "primary", "secondary" or "danger", flat on the legacy descriptor and under options.variant in the attributes block — sets the gl-poi-popup__action--<variant> modifier and a data-gl-variant attribute, on the popup and the side panel alike. Any other value keeps the default look and warns once. No earlier release read the key: every action rendered the same button.

  • Consecutive actions share one row in the popup — .gl-poi-popup__actions, a wrapping flex row. They used to stack one per line.

    ⚠️ The buttons are no longer direct children of .gl-poi-popup__body. A stylesheet that reached them with a child selector (.gl-poi-popup__body > .gl-poi-popup__action) no longer matches: target them through the row, or with a descendant selector.

  • modules.feature-info.popup.closeButton shows MapLibre's close cross on the popup. The default is unchanged (false): Escape and a click on the map still close it.

  • A boot that cannot complete says so. geoleaf:boot:failed is dispatched — reasonconfig, profile, webgl, map, module, timeout or internal — and the app shell's #gl-loader veil turns into a failure screen offering « Reload » and a diagnostic to copy or download. The event is cancelable: an application that calls preventDefault() draws its own interface, and a page without the veil gets no DOM at all. Before, every one of these failures left the loading spinner turning forever.

  • A profile resource that fails is named. geoleaf:profile:failed lists the resources the profile declares and could not obtain — an HTTP error, invalid JSON, or a declared section the profile bundle does not carry. When a map can still show, the reveal is held on a screen that names them, with « Continue anyway ». A resource the profile does not declare is never reported.

  • A missing WebGL2 is named. MapLibre's GPUInitializationError now reaches the boot (MaplibreAdapter.initError, MaplibreAdapter.isGpuInitializationError) and becomes reason: "webgl", with a message the user can act on.

  • A boot watchdog — GeoLeaf.boot({ watchdogMs }), default 45 s, 0 disables it, paused while beforeBoot runs. A boot that stops progressing gets a provisional timeout and a « Reload » screen, which a late reveal removes.

  • A module that throws no longer takes every later module down. ModuleRegistry.init() accepts { onModuleError } (ModuleInitOptions, ModuleInitFailure): answering "continue" skips the failed module's dependents — left out of getActiveModules(), never destroyed — and starts every other module. Without the option, init() still rejects at the first failure. The boot passes it: a module the interface depends on fails the boot (geoleaf:boot:failed, reason: "module", module naming it); any other is reported by geoleaf:module:failed (cancelable), and the reveal is held on a screen that names it — or, once the application is revealed, a warning names it.

  • The log keeps a record support can read. GeoLeaf.Log.getEntries() returns the latest 500 entries — warnings and errors whatever the level, info and debug when they reach the console, the boot logger's lines and, from GeoLeaf.boot() on, uncaught errors and unhandled rejections — formatted into bounded text and redacted when read: URL credentials, parameters named like a secret, Bearer and encoded Basic credentials, JSON Web Tokens, JSON members whose key names a secret, e-mail addresses (the t cache token is kept). GeoLeaf.Log.exportDiagnostic() returns them as a JSON document. The boot failure screen's diagnostic now carries these entries and the modules that failed, every string redacted.

  • GeoLeaf.Events.on() and once() return the function that removes the listener — what the published IEventBus.on contract already promised, while the facade returned undefined. An anonymous handler can now be removed; the function returned by once() cancels a listener that has not fired yet. It removes the very handler reference the call registered, so off(event, handler) keeps working unchanged. Without a document, the function does nothing.

  • prefetch on a declared data origin (modules.offline.dataOrigins[].prefetch, DataOriginDeclaration.prefetch) — the integrator's statement that an origin's terms allow its resources to be downloaded ahead of use. It is distinct from cacheable, which only keeps what the page already requested; it is ignored without cacheable: true and dropped from an authenticated declaration.

Changed ​

  • A profile's write.dialect now defaults to collection, the dialect the core writes. The schema announced rest, which the offline drain refuses: a layer that did not name a dialect was documented as one whose writes the core would set aside. No profile of this repository declares one, and a declared value is unchanged.

  • A profile whose profile.json fails no longer boots on the base configuration. The boot stops on the failure screen instead of starting a map without its profile, in silence.

  • A module that throws during the boot no longer stops the modules that do not depend on it. They used to be skipped with it, with nothing more than a console warning. Its dependents are still skipped; when the interface depends on it, the boot fails and names it.

  • A beforeBoot hook that throws now hides the #gl-loader veil. It used to stay up forever, over whatever the application drew next. geoleaf:boot:aborted is unchanged, and is now typed in GeoLeafRawEventMap.

  • The offline preparation no longer downloads tiles from an undeclared third-party origin. A basemap flagged offline: true, or a tiled layer, used to be downloaded whatever its origin — including tile.openstreetmap.org, whose usage policy states that offline use is not permitted. Its tiles, style, glyphs and sprite now come only from the page's own origin, or from an origin declared with cacheable: true and prefetch: true; anything else is skipped, with a warning naming the origin, and a refused style is not even fetched. enableTileCache keeps its default.

    ⚠️ Migration: an application that prepares tiles served from another origin — its own tile server on a separate host, or a provider it holds a licence for — adds { "origin": "https://…", "roles": ["tiles"], "cacheable": true, "prefetch": true } to modules.offline.dataOrigins. Declare an origin only if its terms allow downloading ahead of use.

  • The coordinates readout is no longer shown under a coarse pointer unless the profile asks for it. It follows the mouse only: on a touch screen it showed « Lat : --, Lng : -- » for good. An absent modules.coordinates.enabled now means shown on a fine pointer and not under (pointer: coarse); true shows it everywhere, false hides it everywhere. GeoLeaf.Coordinates.isEnabled() and getConfig().enabled report that resolved value.

  • The basemap credit is displayed. The map was created with MapLibre's attribution control turned off, and nothing replaced it: the attribution of a basemap never reached the screen, although tile providers require it. MapLibre's control is now on, in the bottom-right corner — a button below 640 px wide (expanded at load, collapsed at the first drag), expanded where it fits. The attribution a profile declares on a vector basemap (type: "maplibre"), read by no code until now, is added to the incoming style's sources that declare none. From 1440 px wide, the corner steps out of the right-hand panel. IMapAdapter.buildStyleChangeTransform accepts { attribution }.

    ⚠️ An application that displayed the credit itself — through modules.branding.text, for instance — now shows it twice: drop its own copy.

Removed ​

  • BREAKING — GeoLeaf.Security.CSRFToken and the csrf write authentication. The module minted its token in the browser and checked it in the same context: no server could verify it, so it protected nothing while reading like a protection. Removed with it: the geoleaf:csrf:refreshed and geoleaf:csrf:rotated events, and the "csrf" value of WriteAuth and of the profile schema's write.auth, a value no code read. @geoleaf-plugins/editor no longer sends an X-CSRF-Token header with an image upload.

    ⚠️ Removed without a prior announcement — an exception to the versioning policy, decided on 2026-09-13: nothing the module did could be relied on. Migration: authenticate writes with the bearer token of @geoleaf-plugins/connector, which adds it to the requests it intercepts. A profile that declared write.auth: "csrf" no longer validates: use "bearer" or "none" — the key is declarative, and no code reads it.

Fixed ​

  • Modifying or deleting an existing entity reaches its server row. With the offline capability on, @geoleaf-plugins/editor queues every write, and it names an existing entity by the id the map shows — its server identity. The store keyed that entity differently, so the edit landed on a second record with no server identity and the request went out as ?id=eq.null: every modification and deletion of an existing entity missed its row, whether the layer had been downloaded or not. Storage.applyEdit now resolves such an identity to the stored entity of the same layer, or files a never-downloaded one under srv:<id>; its report returns the key the edit landed on. A modified entity is no longer drawn twice offline, a deleted one leaves the offline map, and a modification whose entity has no server row yet is no longer sent.

    ⚠️ Captures already set aside by this defect carry the server's refusal (rejectedByServer), which is not requeueable. Editing the entity again sends a fresh edit that does reach the row; the old entry is then discarded through Storage.discardQuarantined. A device that had them keeps one extra record per entity — the one the defect wrote — and a downloaded layer draws that entity twice until it is removed.

  • A dead session no longer sets aside one capture a minute. The drain stops at the first 401, which is right, but every trigger afterwards met the same dead session and set the next capture aside: a tour went to quarantine one entry at a time, and nothing brought them back. The core's automatic triggers — network return, wake-up, periodic tick, a fresh write — now wait while a pass has stopped that way, and start again on the first pass that does not. A send asked for by hand is never gated. @geoleaf-plugins/connector 1.3.1 closes the loop: on a successful sign-in or a renewed token it calls Storage.requeueAll("authRequired") then Storage.pushOutbox() — the two public gestures an application that signs in by its own means makes too.

  • A deletion that meets a concurrent change is no longer lost in silence. The zero-row answer was read for a modification only, so a filtered DELETE answered 200 [] — the row had changed since the capture — came out a plain success: the entry left the queue, the record left the store, and the row stayed on the server until the next download brought the entity back. The conflict is now detected and logged for a deletion as it is for a modification, then settled by the same policy — lastWriteWins, so the deletion is sent again without its filter and wins.

  • A write no longer turns the next one into a conflict. The record kept the freshness marker it had before being sent, while the server moves it on every write: the next modification of the same entity was filtered on a stale marker and counted the device's own write as someone else's. The record now takes the marker returned with the written row, and an edit made while the previous write was on the wire is based on that write.

  • An edit made while its entity was being sent is kept. The drain wrote back the copy of the record it had read before the request, so a correction made during the request was overwritten, marked as sent, and the queued correction then sent the old state.

  • The Quick Start of the README creates a map. It passed Core.init() the { map: { target } } shape, which only GeoLeaf.init() reads, and never put the engine on globalThis.maplibregl, the one place GeoLeaf reads it from: the call logged an error and returned null. It now installs maplibre-gl, sets Object.assign(globalThis, { maplibregl }) and passes mapId. The npm recipes of the documentation had the second defect, and the COOKBOOK and FAQ loaded a profile through Core.init({ configUrl }), an option Core.init ignores — they now use GeoLeaf.boot({ configUrl }). Core.init itself gains the TSDoc its declaration was published without.

  • .gl-poi-popup__hero has a rule — full width, height capped at 160 px, cover crop, rounded corners. The class was emitted with no stylesheet.

  • An invalid inline configuration no longer hangs the boot. It rejected the loading promise of GeoLeaf.loadConfig without calling onError, and the boot waited forever for a callback.

  • Full-height panels fit a mobile browser tab. .gl-page, the feature side panel, the filter panel and the share dialog were sized in 100vh — the LARGEST viewport, browser toolbar hidden — so their bottom band sat under the toolbar, out of reach. They now use 100dvh, with 100vh kept as the fallback.

  • A stalled photo upload no longer loses the photo. @geoleaf-plugins/editor sent it with no time limit: on a network that stopped answering, the upload never ended, the photo was neither sent nor kept, and closing the form lost it. The upload now gives up after 30 s plus the time a 256 kbit/s uplink needs to send the file, and the photo is kept on the device and sent again later, as after any other failure.


[3.3.0] - 2026-09-09 ​

Added ​

  • geoleaf:print:preview:ready joins the typed event map (GeoLeafEventMap). Emitted by @geoleaf-plugins/print when the preview image is actually painted.

    ⚠️ It is not a synonym of geoleaf:print:render:end, and the distinction matters if you automate the print modal. render:end fires when the off-screen map goes idle, while the capture copy, the page composition and the JPEG encoding are all still ahead — around 17 Mpx of synchronous work at A3/300 dpi. Code that waited on render:end to mean "the preview is ready" resumed inside that block. Wait for preview:ready instead. render:start / render:end are unchanged and keep bracketing the off-screen render.


[3.2.0] - 2026-09-08 ​

Fixed — the offline drain no longer loses, orphans or condemns a field capture ​

Four defects on the same nominal path — a capture, a network cut, a return of network — each of which lost or condemned a capture without saying so.

An entry left inFlight by a dead session is now reclaimed. inFlight is written before the call and cleared by its outcome; a tab killed in between — the ordinary case on a phone that sleeps mid-sync — left the entry in a state nothing replays and no gesture exits. The capture was never lost, which is the worse half: the operator watched a due count that would not go down, on a queue whose "Retry" did nothing for that entry. The drain reclaims it in head of pass, and it is the entry's AGE that decides (new OutboxEntry.inFlightAt): a young one belongs to another tab that is sending it, and re-sending a DELETE earns a 404 — an immediate quarantine on an operation that had succeeded.

A 409 on a create now re-reads the server identity. A 409 says "I already have it"; it does not say WHICH row. The entry left the queue with serverId still null, so the entity was reputed synchronised locally and unreachable server-side, every later update filtering on id=eq.null. The create now re-reads through local_id — the key the UNIQUE constraint refused. If no row comes back, the 409 came from another constraint and nothing was written, so it is a refusal; if the re-read cannot conclude, nothing is decided and the entry stays replayable.

The second send of a lastWriteWins conflict is verified. The re-send drops the freshness filter, and that outcome was never checked: a 200 [] — the form in which this server says "zero rows" — emptied the queue while nothing had been written. Unfiltered, zero rows means the row is GONE, so it takes the same motive a 404 does. A null body still proves nothing and is not read as zero.

The replay budget counts time, not gestures. Three total attempts is defensible for a network coming back and absurd for three drains fired inside one minute — and the drain fires on network return and on the "Retry" button, with attempts persisted. Each failure now writes a nextAttemptAt on the entry (30 s, then ×4, capped at 8 min) that the drain walks past. Re-editing a failed capture resets its budget: a correction is a new intention, not a replay.

Fixed — an expired session no longer destroys field captures ​

A token that expires during a tour was unrecoverable, and the loss was silent. Three mechanisms had to be wrong at once, and they were.

The refresh could not be reached. On a 401 the interceptor cleared the token — RAM and IndexedDB — before attempting anything; the re-read that followed found nothing, so the refresh delegate was never called. And the delegate itself read a cache that evicts expired entries, so even when reached it gave up before the network, in exactly the case it exists for. The renewal is now attempted first and the token erased only after it fails; the delegate reads the stored record, expiry included — the server is the one that arbitrates.

The renewal request was being intercepted by the patch it travels through, which would have made it await its own in-flight promise and would have overwritten the Authorization header it carries — the expired token it exists to present. The authentication endpoint is now out of the interception perimeter.

A 401 was reported as rejectedByServer, the one quarantine motive the contract defines as "replay cannot fix" — so a capture was set aside with destruction as its only exit, for a cause that lifts by itself. It now takes the new authRequired motive, which is replayable, and it halts the drain: everything queued behind meets the same dead session, and in token mode the requests that follow no longer carry an Authorization header at all. 403 deliberately stays out of this: a known identity lacking a right is not fixed by signing back in.

And the drain reported a complete pass over a queue it had barely touched. skipped was hard 0 under a comment stating the drain "skips nothing" — true when written, false since the retry backoff landed. It now reports what was walked past.

Added ​

  • GeoLeaf.UI.destroyMobileToolbar() — the mobile pill had no teardown, and therefore no path back. Core.reattach() documented a rebuild recipe for #gl-right-panel only, while writing "and its siblings": those siblings are four nodes initMobileToolbar appends to the same shell, and a host following the recipe repaired the right half and broke the left. There are now two recipes, one per surface — destroyDesktopPanel() → initDesktopPanel() → activateDesktopPanel() for the panel, destroyMobileToolbar() → initMobileToolbar() for the pill. 🛑 Call either destroy BEFORE dropping the shell: both hand back nodes they had moved elsewhere — the mobile sheet holds the filter panel, the legend and the layer manager while open — and they can only hand them back to a parent that still exists.
  • initMobileToolbar() is now idempotent per shell. The guard tests ownership (parentElement === glMain), not presence in the document: a host that mounts a new view before unmounting the old leaves two live shells, and a presence guard would find the old pill, do nothing, and leave the new view without a toolbar.
  • GeoLeaf.Connector.logout() — ending a session was impossible: the surface carried configure and openLoginModal only, and a device handed back kept a valid Bearer until expiry. It is a no-op in getToken mode, where the host owns the token and the plugin holds no copy.
  • geoleaf:connector:signed-out — its counterpart :auth-error says the session DIED; this one says it was closed. A host must be able to tell them apart: one calls for a login window.
  • Guided reconnection: :auth-error had two emitters and no listener, so the session ended in silence at the worst moment. The login window now reopens — once per failure, and only when auth.ui was asked for.
  • QuarantineReason.authRequired, requeueable, and pushOutbox() reports haltedBy.
  • GeoLeaf.Storage.requeueAll(reason?) — the batch exit from quarantine. The per-entry requeueQuarantined(id) takes a contract identifier that nothing displays, which is why it had no caller; yet what produces quarantines is never one entry. It delegates to the per-entry exit, so the rule that decides keeps one author, and what that rule refuses is counted in skipped.
  • pushOutbox() reports deferred — entries walked past because their retry delay had not elapsed. Without it, a drain that does nothing is indistinguishable from an empty queue.
  • OutboxEntry.inFlightAt and OutboxEntry.nextAttemptAt, both optional and additive.

Fixed — a locally-held photo has a return address, and the store can be read back ​

local_images records now carry endpoint, layerId, localId and fieldPath, and the module gains bindLocalImage(id, owner) and getLocalImage(id). No schema version change: these are unindexed fields on an existing store.

storeImageLocally rebuilt its record field by field, so endpoint — which the edit plugin did pass — was dropped in silence. Its retry loop opens with if (!img.endpoint) continue, so it skipped every image and reported a successful pass having attempted nothing. And no record referenced the feature carrying it, so an upload that did succeed had nowhere to send the resulting URL back to — which is why that URL used to be discarded.

The address is written after the fact, and it cannot be otherwise: a photo is taken while the form is open, hence before the feature exists — off-network its client identity is only minted when the edit is enqueued. Binding an image the caller no longer knows about is a no-op rather than an error: a token pointing at an already-purged record is an ordinary outcome of a long session, and raising there would fail a save.

getLocalImage had been removed on 08/08/2026 as redundant with the base64 data-URL storeImageLocally also wrote into the entity's data. That data-URL is gone — it was the defect, since it put the whole photo inside the attribute — so this read is no longer a second path to the bytes: it is the only one.

updateImageUploadStatus takes an object, and its relay declared the parameter string. The module reads status.uploaded and status.url; a caller passing the literal "uploaded" therefore wrote the record back as still pending, never stored the URL, and left cleanUploadedImages — a cursor over the index at 1 — with nothing to reclaim, indefinitely. The same photo left again on every reconnection and every boot.

[3.1.0] - 2026-09-01 ​

Fixed — a mixed-geometry layer no longer paints its features in EVERY sub-layer ​

One declared GeoJSON layer becomes several MapLibre layers — fill, casing, line, circle, symbol — over a single source. Each of them now carries a geometry-type filter, so a sub-layer only paints the geometry it was built for.

Why it changed. MapLibre performs no geometry-type check when it fills a bucket: FillBucket triangulates the rings it is handed — a LineString included, which it closes into a filled polygon — and CircleBucket walks every point of every ring, so one line contributes one circle per vertex. A layer carrying more than one geometry kind therefore rendered each of its features in all of its sub-layers. Seen on a computed itinerary: the line drawn correctly, an opaque black polygon closing it, and a hundred dots where there were three stops. It also made the fill layer intercept clicks over the whole enclosed area.

Who is affected. Any layer whose source carries several geometry kinds, and any layer whose data ships empty and is written at runtime through GeoLeaf.Layers.setData() — such a layer cannot be scanned at creation, so all three sub-layer families are built for it. Homogeneous layers are unaffected: the filter matches everything they hold.

⚠️ setLayerFilter() composes with the guard instead of replacing it, and clearing a filter (setLayerFilter(id, null)) restores the guard rather than removing every filter. A caller reading back map.getFilter() on a GeoLeaf sub-layer will see the composition, not the expression it passed. Vector-tile layers are untouched — a source-layer is homogeneous by construction.

Changed — a colour a style does not declare falls back to #cccccc, no longer to MapLibre's black ​

fill-color, line-color and circle-color were simply not emitted when the style declared no fillColor / color, which left MapLibre's own default in place: an opaque #000000. A layer with an incomplete style therefore rendered as solid black, which reads as data rather than as an omission. The styleRules path never had that hole — it already fell back to the same #cccccc — so the two paths now agree.

⚠️ Size defaults are deliberately left alone. circle-radius, line-width and circle-stroke-width still fall back to MapLibre's values: most styles in the wild omit radius, and moving that default would resize nearly every point layer for a defect that is not this one.

Changed — a layer's declared geometryType is finally read, and it ADDS rather than restricts ​

The adapter's fast path only ever accepted GeoJSON names ("Polygon"), while the profile schema allows nothing but the lowercase vocabulary ("polygon", "polyline", "point") — so no profile ever reached it. Both vocabularies are now understood.

🛑 A declared kind adds sub-layers, it never removes any: only the data may say "unknown". A layer's declared kind is its semantic kind — the legend and the layer manager read the same field — and it can be narrower than its content (a computed itinerary is a polyline that also carries its stops). Declaring a kind now guarantees a sub-layer exists for what the boot data cannot show; it never narrows a layer down to that kind alone.

Added — boot({ config }) and boot({ configUrl }): the configuration is now INJECTED, not deduced ​

GeoLeaf.boot() accepts two new options. config hands the configuration over in memory: the boot applies it directly and issues no request for it. configUrl names an explicit URL to fetch it from. config wins over configUrl; without either, the path is still derived from the host page, unchanged — no existing integration moves.

Why it changed. The configuration path was an implied location, and an embedding application cannot always make the page imply it. Measured against a host router that answers unknown paths with its own HTML document in HTTP 200: the derived path returned HTML where JSON was expected, and the failure surfaced five errors away from its cause. config removes the request; configUrl removes the deduction. Both make the location a value the caller states, which is the only form an embedding host can control.

js
// The configuration is already in memory — no request is issued for it.
GeoLeaf.boot({ config: { map: { center: [4.85, 45.75], zoom: 12 } } });

// Served from somewhere the page cannot imply.
GeoLeaf.boot({ configUrl: "/assets/geoleaf/config.json" });

Migration — possible, not required. _app.getProfilesBasePath stays in place, stays called, and returns the same string. What changes is its status, not its behaviour: it is no longer the only way to say where the configuration lives. An override installed solely to point the configuration elsewhere may be replaced by boot({ configUrl }), or dropped entirely if the configuration is held in memory. Doing nothing is a valid choice.

⚠️ This entry is late, and the lateness left a visible trace. The entry below on the rejecting configuration load referred to GeoLeaf.boot({ config }) as "new in this same release" while no entry described it — a dangling reference in a published changelog, pointing at something a reader could not find. Nothing in ci:local compares a cross-reference to the entry it names.

Removed — BREAKING: GeoLeaf.Sync.getHandlers() (pre-adoption window) ​

The plural accessor is gone from the Sync façade and from the internal registry seam. registerHandler(id, handler) and getHandler(id) are the whole contract — they are what the offline replay path actually uses, one id at a time.

Why it could go in a minor. This lands under the pre-adoption window of VERSIONING_POLICY.md: no consumer follows a semver range today, and the one measured consumer manifest never names the member. The accessor was introspection-only — every one of its ten call sites lived under __tests__/ with the member as its own oracle — and keeping it pinned a registration-order promise nothing needed.

If you did adopt it, enumerate your own registrations instead: you know the ids you registered; resolve them with getHandler(id).

Fixed — the proximity radius slider dropped fractional values ​

A profile is free to declare fractional radius bounds on its kind: "proximity" filter field — radiusStep: 0.1 for 100 m granularity, radiusDefault: 0.5 for a 500 m starting radius. Those bounds reached the <input type="range"> intact, then two parseInt reads truncated them.

What you saw. Every radius below 1 km collapsed to zero: the circle vanished and the filter kept nothing. Above it, values were rounded down — 1.4 km became 1.0 km. With radiusStep: 0.1 over a 0.1–10 km range, the slider offered 99 notches and produced 11 distinct radii, the first 9 of them dead. The first click on the map was worst: the toolbar button read the slider's default the same way, so proximity armed at a radius of 0 before the slider had been touched at all.

And the label disagreed with the map. It was written from three different sources — the configured number when the bar was built, the truncated integer while dragging, the raw defaultValue string on reopen. So the pill could read "0.5 km" next to a circle of radius zero, then flip to "0 km" on the first movement. The displayed radius and the applied radius are now read from one number.

Also fixed. GeoLeaf.Filter.proximity.toggle(map, 0) used to pass the zero straight through to the circle — the ?? 10 default only caught null and undefined. Any non-finite or non-positive radius now falls back to 10 km.

The geometry was never at fault: the drawn polygon and the filter predicate already stood on the same EARTH_RADIUS_M, and still do.

No action needed. Integer bounds behave exactly as before, and the in-core defaults (min: 1, max: 50, step: 1, default: 10) are unchanged.

Changed — the proximity radius label goes through the dictionary ​

The pill's radius label was built from an inline `${…} km` template in three places, while format.proximity.radius sat declared in all six language files with no consumer. It is wired now, so the unit is overridable like every other label.

BREAKING — a failed configuration load now REJECTS instead of degrading ​

GeoLeaf.Config.loadUrl() used to swallow its failure: an unreachable URL, invalid JSON, or a content type refused by strictContentType were logged, and the configuration already in place was returned. The promise resolved. It rejects now, and the boot stops.

Why it changed. Because the resolution made the failure unattributable. Measured in a real browser against a host router answering its own HTML document in HTTP 200 on the configuration path: the console carried two precise errors — then, because the promise had resolved, the initialisation manager announced "Configuration loaded successfully", and five consequence errors followed. None of them named the cause. An integrator read a success, then five map failures, and went hunting the map. Logging the cause made the failure legible; only rejecting makes the contradiction impossible.

Who this breaks. Any caller that relied — knowingly or not — on the boot continuing after a failed configuration load. There is no silent path any more: the failure surfaces where it happens.

What to do. If the configuration is already held in memory, GeoLeaf.boot({ config }) (new in this same release) removes the request entirely and the question does not arise. Otherwise, handle the rejection where you call loadUrl.

⚠️ This entry is late, and that is worth recording. The change landed on 20/08/2026; this entry was written after a review found that the published .d.ts still promised the opposite behaviour ("Failure is contained, not thrown"). The deprecation policy added below exists because three removals had already shipped under that same silence — this is the first case that tested it, and it failed the test until the review caught it. Nothing in ci:local can see a sentence that stopped being true.

Added — a deprecation policy, and the gate that makes it verifiable ​

VERSIONING_POLICY.md gains a Deprecation section. Until now the file said a breaking change lands in a MAJOR without saying how one gets there — so nothing described what an announcement is, how long it must stand, or what counts as one. Three removals had already happened under that silence, each leaving a trace in a different form, none of them machine readable, and two of them broke an external consumer without warning.

The policy names three artifacts, all required: the @deprecated TSDoc tag on the symbol, an entry here, and an entry in docs/reference/consumers/DEPRECATIONS.json. Only the first travels inside the npm tarball — it rides in the emitted .d.ts — which is why it is required rather than recommended.

The rule on timing: the announcement must survive at least one published minor, and removeIn is the next MAJOR. A release that both announces and removes announces nothing.

This is enforced rather than declared. CC-10 of scripts/verify-consumer-contract.cjs refuses a path that leaves the consumed public surface without a register entry and a real tag on its symbol.

Changed — three maxRetries aliases are no longer tagged @deprecated ​

CacheManagerConfig.maxRetries, RetryConfig.maxRetries and RetryOptions.maxRetries are kept aliases, not deprecations: they are normalised into maxAttempts on read, and nothing schedules their removal. They carried a @deprecated tag, which struck the key through in editors and promised a removal that was never planned.

The 3.0.0 entry below describes maxRetries as "a deprecated, normalised alias". The normalisation is unchanged and no configuration behaves differently; what changes is that the tag no longer claims a removal. Under the new policy, @deprecated marks a symbol that is going away — a kept alias is a different statement, and conflating the two makes the tag lie to autocompletion.

Added — Core.isAttached() and Core.reattach() ​

GeoLeaf.Core gains two members for hosts that move a map around their layout instead of destroying and rebuilding it — a full-screen toggle, a tab switch, a panel that re-mounts.

ts
const Core = GeoLeaf?.Core;
if (Core && !Core.isAttached("main")) {
    Core.reattach("main", document.getElementById("fullscreen-slot")!);
}
  • isAttached(mapId): boolean — the map is registered and its container is still in the document. Deliberately distinct from hasMap(), which only answers the first half: a host that removes the map's subtree without calling destroy() leaves a registered map that renders nowhere, and hasMap() still says true. Returns false after destroy(), never throws.
  • reattach(mapId, parent): boolean — moves the live map into another parent. The whole container is re-parented; its children are not moved individually. That is imposed by the engine: MapLibre memorises the element it was constructed with, so moving the children leaves map.getContainer() pointing at the old node and every subsequent measurement wrong.

⚠️ The panels do not follow the map. #gl-right-panel and its siblings live in the shell, not inside the map container, so they stay where they are. Rebuilding them at the new location is the host's call, through three already-public exports: GeoLeaf.UI.destroyDesktopPanel() → initDesktopPanel() → activateDesktopPanel(). Making them follow would tie this API to the shell's DOM — the coupling it exists to remove.

The map adapter contract gains an optional resize?(): void, which reattach() calls after the move so the WebGL drawing buffer picks up the new container size. Optional, like getNativeMap?(), so test doubles stay conformant. Custom adapters that do not implement it are accepted; their canvas will keep its pre-move dimensions.

Changed — GeoLeaf.getMap() and GeoLeaf.getAllMaps() return the live map ​

The top-level shortcuts read the same registry as GeoLeaf.Core. They previously returned null and [] for every live map, whatever the application did: they read a mirror registry that only GeoLeaf.createMap() ever filled, and the boot path does not go through it. The mirror is gone.

ts
GeoLeaf?.getMap?.("main") === GeoLeaf?.Core?.getMap("main"); // true — was null === adapter
GeoLeaf?.getAllMaps?.().length === GeoLeaf?.Core?.listMaps().length; // true — was 0

Integrator impact. This is a widening: code that received null now receives an adapter. A fallback written against the old behaviour — GeoLeaf.getMap(id) ?? somethingElse — stops taking its second branch. Nothing that worked before stops working.

APIFactoryManager.removeMapInstance() changes with it: it used to drop the mirror entry and leave the real map running, and now destroys the map, as its name always claimed.

Changed — geoleaf:popup:action carries button, setBusy() and close(), and is no longer JSON ​

The detail of geoleaf:popup:action gains the three members that GeoLeaf.Popup.registerActionHandler provided before ADR-07 removed it: button (the clicked node), setBusy(busy) (toggles disabled, aria-busy and gl-poi-popup__action--busy) and close() (closes the surface the button was rendered in — the popup or the side panel, never both). A host action can finally show a pending state, close on success, and report a failure in place.

ts
GeoLeaf.Events.on("geoleaf:popup:action", (e) => {
    const d = e.detail;
    if (d.actionId !== "tickets:create-request") return;
    d.setBusy(true);
    void createRequest(d.featureId)
        .then(() => d.close())
        .finally(() => d.setBusy(false));
});

Integrator impact — the payload is no longer serialisable. JSON.stringify(e.detail) now throws (circular DOM reference), and passing the detail to postMessage or a Worker throws DataCloneError. If you log, forward or persist the detail, copy the fields you need first: const { actionId, layerId, featureId, properties } = e.detail;. Subscribing is unaffected — GeoLeaf.Events.on/off/once accepts this key exactly as before, with the same type inference.

The event is dispatched as a raw CustomEvent and its key moved to GeoLeafRawEventMap, joining geoleaf:toolbar:action and geoleaf:layer-manager:panel. Routing it through the sanitising bus would have delivered button as {} and the two functions as undefined without any error — which is why the map is split rather than the payload.

⚠️ Two corrections to previously published documentation, both of which were wrong before this release:

  • EVENTS_API.md announced that properties defaults to id, name, title, label. It never did: without payloadFields, properties is {}, and has been since the feature shipped on 2026-07-29. The default goes to confidentiality — this is a document event any script on the page can hear. If you relied on the documented default, declare payloadFields explicitly.
  • Action buttons were attributed to a plugin named @geoleaf-plugins/feature-info. No such package exists; feature-info is a capability built into @geoleaf/core.

Fixed at the same time: an action button clicked in the side panel emitted featureId: null and no lngLat, because that surface passed only layerId to the render context. Both surfaces now pass the full context. The popup was unaffected.

⚠️ Widened attack surface, stated rather than implied. The detail now carries a function that closes the popup, on a document event. Any script on the page can call it — as it could already call document.querySelector(".gl-poi-popup__action").click(). The confidentiality rule on properties is what guards this channel, and it is unchanged.

Changed — the six @geoleaf-plugins/connector events are now prefixed geoleaf:connector: ​

connector:authenticated, connector:token-refreshed, connector:auth-error, connector:credential-button-clicked, connector:signup-requested and connector:forgot-password-requested become geoleaf:connector:…. They were the only public events in the product outside the geoleaf: naming domain.

This was not cosmetic. The repository's event coverage gate is anchored on ^geoleaf:, so all six were structurally invisible to it — never typed, never listed, never counted. Prefixing them made the gate demand all six in the same run, and they are now typed in GeoLeafEventMap alongside every other public event.

Integrator impact — this breaks listeners. Any document.addEventListener("connector:…") must be renamed; the old names are no longer emitted. The payloads are unchanged, and the two cancelable events (signup-requested, forgot-password-requested) keep their contract: preventDefault() still blocks the link navigation.

⚠️ If you also run the downstream connector plugin, note that geoleaf:connector:* is now a shared namespace. The six names above do not collide with the six it emits (ready, bbox-loading, bbox-loaded, data-version-changed, error, auth-required), but nothing prevents a future clash — check both lists before adding a name.

Added — geoleaf:panel:opened / geoleaf:panel:closed, and two POI panel events that finally fire ​

The desktop tab panel (layers / filters / legend) now reports both directions, with { tabId }. Switching tab emits closed then opened, in that order; opening from a closed panel emits opened alone, and closing an already-closed panel emits nothing. Both the pointer path and GeoLeaf.UI.openPanel() are covered — an event describing only the programmatic path would be half a contract.

geoleaf:poi:panel:open and geoleaf:poi:panel:close were declared in the published types since v1 and never emitted: a listener compiled and never ran. They are now emitted by the feature-information side panel. poiName resolves through the same path as the displayed title, so the event carries what the user reads.

⚠️ A feature without a stable id emits nothing rather than a forged poiId — two anonymous features would otherwise be indistinguishable to a listener.

⚠️ Two panel families, and they are different panels: geoleaf:poi:panel:* is the feature drawer, keyed by poiId; geoleaf:panel:* is the desktop tab strip, keyed by tabId.

Added — nine geoleaf:table:* events and geoleaf:geojson:visibility-changed are typed ​

The table plugin's nine events (opened, closed, layerChanged, sortChanged, selectionChanged, zoomToSelection, exportSelection, exportLayer, highlightSelection) now appear in GeoLeafEventMap with their payloads. They were emitted all along, but their names were assembled at runtime, so no tooling could see them.

No behaviour changed and no name changed — only the declarations. Each of the nine is emitted on both document and the MapLibre bus: subscribe to one, never both.

geoleaf:geojson:visibility-changed is typed too. Prefer geoleaf:layer:toggle for new work: it carries the same payload and fires for every source, whereas visibility-changed is not re-dispatched on document when the change comes from a zoom recalculation.

Fixed — a click on a feature now emits one geoleaf:feature:click, whatever its geometry ​

The kernel bound the click handler to every sub-layer of a layer, while it bound hover to a single one. Sub-layers are stacked per geometry and cumulatively, so a single click emitted 2 events on an icon point, 2 on a cased line, 3 on a cased polygon and 4 on a vector tile. Downstream, feature-info closes and reopens its popup on each event, so the popup flickered and the second render replaced the first for the same feature.

Click and hover now share one selection (fill → circle → line → all), so a gesture cannot be reported twice. Integrator impact: a listener on geoleaf:feature:click that de-duplicated events itself can drop that workaround; one that counted events will now count fewer.

⚠️ On an icon point the clickable area becomes the circle rather than the icon's collision box. Nothing becomes unreachable — every point renders a -circle sub-layer unconditionally — and nothing is lost on the axes; only the diagonal ring of the icon's padding is.

Fixed — pre-cached assets are served offline again (PWA second load) ​

The service worker wrote its pre-cache into one bucket and looked for it in another: install derived its scope from the injected asset list, while fetch decided from a file-extension list that does not accept json. Two pre-cached entries — manifest.json and the root profile config — were therefore stored and never found, and the application could not boot offline on a second load for want of its configuration.

The router now resolves membership against the pre-cache itself, so what install writes is what fetch reads. No configuration change is required.

Changed — an HTTP 501 from the editor backend is no longer treated as a network failure ​

@geoleaf-plugins/editor classified every non-4xx response as "network", which auto-adapter treats as retryable — so a 501 (the server does not implement the verb) was queued for retry and the user was told to try again. It never would have succeeded.

A 501 now raises PersistenceError with the new kind "capability", is never queued, and surfaces a dedicated message (editor.error.operationNotSupported, added in all six locales). This aligns the editor with the decision the core already applies to its own offline queue. Integrator impact: code branching on err.kind === "network" to detect a 501 must branch on "capability" instead.


[3.0.0] - 2026-08-12 ​

Major release v3.0.0. It consolidates the whole v3 effort: dissolution of the POI subsystem into generic point layers, taxonomy v3 (the point symbol), extraction of the optional modules into in-core capabilities and MIT plugins, multi-instance rework, security hardening (strict style-src CSP) and hard removal of all legacy (deprecated API aliases, re-export shims, format fallbacks, legacy configuration keys).

The jump from the last version published on npm (2.1.8, 2026-05-13) is a large one. Breaking changes are numerous; where a migration path exists it is written next to the entry that needs it.

WARNING

taxonomy and feature-info are capabilities built into @geoleaf/core, not separate npm packages. Entries below that mention @geoleaf-plugins/feature-info and @geoleaf-plugins/taxonomy as external plugins to install or load through a <script> tag are obsolete: both capabilities ship inside the core bundle (geoleaf.esm.js + dist/geoleaf-main.min.css) and are enabled by configuration — no extra install, no extra <script> tag. Migration: remove the <script src="dist/geoleaf-taxonomy.plugin.js"> and <script src="dist/geoleaf-feature-info.plugin.js"> tags.

Changed — BEHAVIOURAL BREAKING CHANGE: analyzeMemoryLeaks() no longer returns normal on a browser that measures nothing ​

GeoLeaf.Utils.PerformanceProfiler.analyzeMemoryLeaks() returned a verdict that was a constant. It computes growthRate from samples of getMemoryUsage(), which reads performance.memory.usedJSHeapSize — a value Chrome quantises and freezes for the lifetime of the page unless --enable-precise-memory-info is set. The samples were therefore strictly equal, growthRate was exactly 0, and warning / critical were unreachable.

The deciding fact is a measurement: on a page deliberately retaining 9.0 to 15.1 MB (confirmed outside the page, through CDP Runtime.getHeapUsage after a forced GC), the API returned {"status":"normal","growthRate":0,"recommendation":"No action needed"} — six times out of six.

What changes

  • status can now be "unavailable", together with a reason: "heap-readings-constant" (every sample in the window equal to the byte) or "heap-api-unavailable" (the window opens on a null reading). Two arithmetic tests, with no threshold to tune.
  • "heap-api-unavailable" closes a second blind spot: outside Chromium, performance.memory does not exist, every sample is 0, and the former computation did (0 − 0) / 0 = NaN. Since NaN is greater than no threshold, the verdict fell back to normal — with a growthRate: NaN published as is.
  • generateReport() carries the unavailability in its recommendations: that is the path an integrator actually reads, and it kept reading "all is well".

What does not change, deliberately

status remains typed string — it is not narrowed to a union, which would break an existing consumer's switch. The only type addition is reason?: string, optional. No signature is modified: the break is one of behaviour, not of compilation.

INFO

A window that moves and returns to its starting point is still normal, with growthRate: 0. Same figure as the frozen case, opposite verdict — because the input did vary.

WARNING

unavailable must not be read as "no leak". It says the browser gave nothing to judge. A caller that treated "normal" as a green light must now handle "unavailable" separately: on a standard Chrome, this API will never return normal again. It has stopped lying, it has not learnt to see — and it loses the right to confirm good health. For a real figure, measure outside the page (DevTools, or CDP Runtime.getHeapUsage after HeapProfiler.collectGarbage).

Added — modules.offline.cache.maxTileCacheEntries, and geoleaf:cache:evicted typed in the contract ​

The Service Worker tile cache was bounded by nothing. It lives in the Cache API, on the same origin as IndexedDB — and browsers evict per origin, not per store. An unbounded tile cache could therefore take sync_queue down with it under disk pressure, that is, field entries that are not yet synchronised and have no other copy.

json
{ "modules": { "offline": { "cache": { "maxTileCacheEntries": 2000 } } } }

A declarable ceiling, default 2,000, 0 disables eviction. Above the ceiling the oldest entries go first; under origin quota pressure the trim becomes markedly more aggressive.

WARNING

Counted in ENTRIES, not bytes — the Cache API exposes the size of no entry, and storage.estimate() measures the whole origin rather than a single store. Not to be confused with maxCacheBytes, which bounds IndexedDB in bytes (default 250 MB): these are two distinct budgets against the same origin quota.

geoleaf:cache:evicted now carries a typed detail, GeoLeafCacheEvictedDetail (contracts/event-bus.contract.ts): { evicted, freedBytes?, totalBefore?, totalAfter? }.

WARNING

The event has two producers, deliberately: cache-manager.ts for IndexedDB, sw-core.js → sw-register.ts for the Cache API. The two stores evict from contexts that can share no code — a Service Worker has no document and can import neither the contract nor the bus. freedBytes is absent on the Cache API side, for that reason: a consumer must handle its absence rather than assume a 0.

Browser measurements ​

Taken on both deployed variants, Service Workers blocked, first load:

Measurementdeploy-coredeploy-full
Third-party origins at boot (unpkg, Google Fonts)00
CSP violations00
Eager chunks started after the entry is received0 / 30 / 3
DOMContentLoaded~75 ms~120 ms
Connections opened in addition to the navigation0 (h2)0 (h2)

WARNING

This is not a paired before/after. The "before" artefact (third-party origins, MapLibre 5) no longer exists on disk, and rebuilding it would mix four separate efforts into a single delta. The values above are the measured state, not an attributed gain. The millisecond figures vary from one run to the next and carry no threshold.

Added — GeoLeaf.Introspection.getCapabilityStatus() ​

One question, one answer: what is switched on, and why. Returns, for each declared capability, { id, embarked, enabled, gate, hasModule }.

To be distinguished from its two neighbours, which answer something else:

  • getAllCapabilities() returns what is declared (the schema);
  • getActiveModules() returns what runs (the initialised modules);
  • getCapabilityStatus() returns the configuration verdict.

enabled is re-read on every call against GeoLeaf.Config, therefore against the merged configuration as soon as the profile is loaded. Before boot(), no configuration exists and each gate answers its enableWhenAbsent — the exact answer to "with what is configured right now", not a fallback value.

WARNING

embarked says where the declaration comes from, not whether the capability appears in the full manifest: true when it comes from a preset installer (the build), false through the runtime channel (GeoLeaf.plugins.registerCapability). Comparing against the full manifest would require importing it, which would cancel the tree-shaking that a purpose-built entry buys.

WARNING

hasModule is a structural fact — the capability contributes an ICoreModule — never a runtime fact, and the id of that module is not necessarily the id of the capability (permalink contributes share). For "is it running", the answer is getActiveModules().

Changed — BREAKING: registerPresetDeclarations() requires a noteInstaller ​

Its second parameter must now carry noteInstaller(id, facts) in addition to register(decl). This only affects code that composes its own preset by calling presets/apply-preset.js directly; the contract is hardened rather than made optional so that an omission is a compilation error and not a silent embarked: false.

Removed — BREAKING: @geoleaf-plugins/addpoi merged into @geoleaf-plugins/editor ​

There is now only one editing plugin. GeoLeaf.AddPOI disappears WITH NO ALIAS — a deliberate decision: the application has no deployed users, and an adoption path would be code to write, test and then delete, for zero beneficiaries.

WARNING

There is nothing to deprecate: @geoleaf-plugins/addpoi was never published. The npm registry returns E404 for it. No version was ever installable, so nobody can have written GeoLeaf.AddPOI against a published package. The migration table below describes an internal switch, not an upgrade path for an integrator.

DANGER

The real breaking change for an integrator is elsewhere, and it is ui.showAddPoi — that key existed in the schema of @geoleaf/core 2.1.8, the published version, and disappears in 3.0.0. It is the only item in this section that a real consumer may have in their profile.

Migration note:

BeforeAfter
GeoLeaf.AddPOI.AddForm.openAddForm(ll)GeoLeaf.Editor.AddForm.openAddForm(ll)
GeoLeaf.AddPOI.PlacementMode.activate()GeoLeaf.Editor.PlacementMode.activate()
ui.showAddPoimodules.editor.showAddPoi
modules.addpoi.defaultPositionmodules.editor.poiAddDefaultPosition
ui.showPoiExport · ui.showPoiSubmitmodules.editor.showExport — see below
<script src="geoleaf-addpoi.plugin.js">nothing: editor loads lazily

WARNING

ui.showAddPoi, ui.showPoiExport and ui.showPoiSubmit are removed from the UIConfig type and from the schema. The last two were declared in no schema at all while ui.schema.json is additionalProperties: false: writing them made profile validation fail. One button was therefore visible without being hideable, the other hidden without being showable. Their replacements live under modules.editor.* and are declared.

WARNING

Default change: ui.showAddPoi was false (opt-in); modules.editor.showAddPoi is true (opt-out), like the plugin's other lazy slots. A profile that did not set the key and did not load addpoi had no button; if it loads editor, it will have one.

What the merge changes in the core:

  • The kernel/ui/poi-addform-seam.ts seam and its contracts/poi-addform.contract.ts contract are removed (180 lines). The core no longer resolves a plugin namespace in order to draw a button: the button is a lazy toolbar slot, declared by the host application.
  • GeoLeaf.Utils.poiToFeature stays — it is a public core API, independent of the plugin.
  • Deployment variants go from 3 to 2: deploy-addpoi disappears, deploy-full carries both editing and offline support.

Changed — BREAKING: enableEdition / enableEditionFull become an edition block ​

The two editability flags of a layer are replaced by a per-operation block. enableEditionFull did not mean "full editing": it was usefully read only once, as canDelete() — it was the right to delete. Nobody guesses that from the name.

Failure mode: layer-config.schema.json is additionalProperties: false, so a profile still carrying the old keys fails validation, with the key named. A clean stop, not a silent degradation.

BeforeAfter
"enableEdition": true"edition": { "create": true, "update": true }
"enableEditionFull": trueadd "delete": true to the same block
"enableEditionFull": falseomit delete (or write it false)

WARNING

The majority case is the most surprising one, and that is intended: a profile that only had enableEdition: true breaks too. It must write create and update separately, and decide about deletion. That is precisely what the former pair could not express.

Semantics — absent means REFUSED, and no key implies another:

edition absent          → create=false, update=false, delete=false
edition: {}             → same — declaring the block grants NOTHING
edition: {create:true}  → create only; update and delete stay false

WARNING

No layer changes state through omission: a layer that declared nothing was not editable before either. The restrictive default therefore takes editing away from nobody — a permissive default would have granted it, silently, to the 42 layers out of 48 that declare nothing.

WARNING

The gate becomes per-operation where enableEdition governed everything. This is not iso-behavioural for create and update: they can now be refused independently.

DANGER

What this block does NOT do, to be read before relying on it. edition.delete is applied on the offline write path only. Online, the editing plugin's REST adapter issues an unconditional DELETE, and its toolbar gates the delete tool on its own configuration, never on the layer. A layer declaring delete: false therefore remains deletable by a signed-in user. This is not an end-to-end authorisation model.

  • Rule A14 is re-anchored: an attributes.fields[].edit field now requires edition.update: true (no longer enableEdition: true), still accompanied by a write block. update and not create, because edit describes modifying an existing value.

Changed — BREAKING for layer configuration ​

  • Attribute rendering is declared in an attributes block at the ROOT of the layer. The capabilities["feature-info"] block and its three parallel lists (tooltip / popup / sidepanel) are removed from the schema: a layer that still carries them no longer validates.

    json
    {
        "attributes": {
            "titleField": "properties.nom",
            "fields": [
                {
                    "field": "properties.nom",
                    "label": "Nom",
                    "primitive": "string",
                    "widget": "text",
                    "display": {
                        "surfaces": ["tooltip", "popup", "sidepanel"],
                        "presentation": { "emphasis": "title" }
                    }
                }
            ]
        }
    }

    A single list, where each field names the surfaces it appears on, instead of three lists of different shapes that had to be kept parallel by hand. The block is strict: it used to live under capabilities, which was additionalProperties: true, so a typo passed silently.

    The type is declared in two columns — primitive says what the value IS in the GeoJSON, widget says how it is shown. That pairing is what lets validate:profiles refuse, at build time, a number asked to be displayed as a date; a single "representation" column would have nothing to check against.

  • The "all" mode is removed, and it triggered on SILENCE. "all", a missing surface and a null surface all went through the same branch: a layer that simply omitted a surface exposed all the properties of its features there — technical identifiers and working columns included. A layer that declares nothing now paints nothing.

    If you relied on that behaviour, declare the wanted fields explicitly. The flat list of what a profile declares is generated by npm run gen:attributes-report.

Fixed ​

  • The same data was displayed differently depending on the surface. A price field rendered a formatted amount in the side panel and [object Object] in the popup; badge and link had the same defect in the tooltip. The three surfaces now share one single value projection.

  • An action field declared on the side panel was neither rendered nor reported. The cause was not a missing branch but a payload: the panel received only the layer id, while geoleaf:popup:action promises featureId and lngLat. Both interactive surfaces now render the button, with its complete payload.

  • coordinates and hours disappeared from the popup, rendered by the panel alone.

  • date, url and email were declared and rendered nowhere — a field carrying one of them disappeared without warning. They now render: date through Intl, honouring a per-field locale, url and email as links. A mailto: address that the URL validator refuses degrades to readable text rather than being dropped — sanitisation is never bypassed.

  • An unknown widget is no longer silent: it is not rendered, it is logged, and it is never fatal — an incorrect profile must not empty the map.

Added ​

  • @geoleaf/core now publishes its ambient namespace. dist/types/global.d.ts is emitted at build time and referenced by the entry point (/// <reference path="./global.d.ts" />), so GeoLeaf.* resolves for the integrator without a manual declaration — in TypeScript as well as in editor auto-completion.

    ts
    // No more `declare const GeoLeaf: any;` in your project.
    GeoLeaf.Layers.getFeatures("my-points"); // typed
  • Public facades carry their documentation in the shipped types. The five offline IndexedDB sub-modules (LayersDBInstance, SyncDBInstance, ImagesDBInstance, BackupsDBInstance, PreferencesAPI), the legend types, ProgressData, ResolverZone, ConfigFacade and the Utils export now document what they do and why — visible directly in the editor.

    Utils carries a clarification that was missing: the ESM export and the GeoLeaf.Utils global have the same shape but are two distinct objects. Mutating one does not affect the other.

Fixed ​

  • The package's npm shop window (README.md) taught a dissolved API and a subpath that does not resolve. It carried a copy-pasteable GeoLeaf.POI.add({…}) — GeoLeaf.POI has been removed since 3.0.0 — an import { Core, POI, Filters } of which two members no longer exist, and two import "@geoleaf/core/dist/…" that throw ERR_PACKAGE_PATH_NOT_EXPORTED: the exports map does not open dist/.

    js
    // The CSS is imported through the declared subpath:
    import "@geoleaf/core/style.css";

    It also announced "Platform V2 / 2.0.0" on a package at 3.0.0, node ≥18 for an engine that requires 22.13, a CDN pin at @2.0.0, and a dead link to docs/poi/. Rewritten as a shop window plus pointers — duplication with docs/ was the mechanical cause of the drift.

  • repository and bugs in the manifest pointed at a frozen mirror repository. They targeted GeoLeaf-Core, whose automatic synchronisation is gone; they now point at the real repository. The "Repository" and "Issues" links on the npm page therefore lead to a living repository.

  • Two public declarations of CacheMetrics depended on a compiler flag or on a non-exported type. No internal consumer exercised either one, so the repository's sixteen tsc -p passes came out green while an integrator no longer compiled.

    • getStorageQuota() — the properties synthesised by a multi-branch ternary went from ?: undefined to ?: never depending on whether exactOptionalPropertyTypes was set. The return type is now explicitly annotated: the published declaration no longer depends on a compilation setting.
    • estimateProfileSize() — its inferred return referenced a non-exported interface, therefore one without an implicit index signature. const r: Record<string, number> = estimate.byType stopped compiling, and the type still shipped in dist/types/ under a name nobody could write. The type is now exported.
  • The getting-started tutorial did not work if followed to the letter. QUICKSTART_TUTORIAL.md asked for layers.json, ui.json and basemaps.json to be created at the profile root, while the profile.json on the same page declared them under config/core/. Structure, Files key and step titles are now aligned.

  • Two dead profile keys were taught as living ones — clusteringConfig and poiConfig. Neither exists in the schema, neither is read by the code. Clustering is configured through the capability: one default in the profile (Files.modules.cluster → config/plugins/cluster.json), one per-layer override.

    jsonc
    // config/plugins/cluster.json
    { "clustering": true, "clusterStrategy": "unified", "clusterRadius": 80 }

    Corrected in QUICKSTART_TUTORIAL, PLUGIN_CONFIGURATION_GUIDE and GEOJSON_LAYERS_GUIDE, where four passages still presented them as the current mechanism.

  • Examples in the published documentation called GeoLeaf.POI.*, removed from the core in 3.0.0. Migrated to GeoLeaf.Layers.* in USER_GUIDE, helpers/ and security/.

  • The configSchema descriptions returned by GeoLeaf.Introspection.getCapabilitySchema() quoted source line numbers. A file:line citation goes stale at the first insertion upstream, and these shipped in the published package. Removed.

Removed ​

  • BREAKING — the @geoleaf/core/presets/preset.contract.js subpath is removed. It becomes @geoleaf/core/contracts/preset.contract.js; the module, its types and its content are unchanged, only the address moves.

    ts
    // before
    import type { PresetManifest } from "@geoleaf/core/presets/preset.contract.js";
    // after
    import type { PresetManifest } from "@geoleaf/core/contracts/preset.contract.js";

    Why. This file is 100% type-only and it was the only one of the repository's 15 .contract.ts files to live outside contracts/. The check that verifies this family remains a pure type surface reads contracts/ without recursion, so the file escaped it entirely, and nothing would have flagged it acquiring executable value — which 21 capability installers would then have pulled into the bundle. Moving it puts it under guard.

    The subpath was not kept as an alias: ./presets/* would have kept announcing a directory the file has left, which is exactly the defect being corrected.

Added ​

  • Three interface selectors, as gated capabilities — GeoLeaf.ProfileSwitcher, GeoLeaf.LanguageSwitcher and GeoLeaf.ThemePalette. Each is switched on by modules.<id>.enabled and is opt-in: absent from your configuration, it adds neither interface nor weight (the code AND its CSS are tree-shaken).

    ts
    GeoLeaf.ProfileSwitcher.list(); // available profiles (collected at deploy time)
    GeoLeaf.ProfileSwitcher.switchTo("france-rail"); // persists + reloads
    
    GeoLeaf.LanguageSwitcher.list(); // available languages
    GeoLeaf.LanguageSwitcher.switchTo("en"); // persists + reloads with ?lang=
    
    GeoLeaf.ThemePalette.list(); // available palettes
    GeoLeaf.ThemePalette.set("green"); // applies LIVE, without a reload
    • profile-switcher — dataset selector at the top of the layer manager. The list comes from data.availableProfiles, generated at deploy time from the profile.json files (a browser cannot enumerate a server directory). Shown only from 2 profiles upwards.
    • language-switcher — language button in the tab strip. No translation added: the 6 dictionaries are already in the core. display: "code" replaces flags with FR/EN on platforms that do not draw them.
    • theme-palette — accent colour (orange / green / blue), carried by data-gl-palette on <html>. Orthogonal to light/dark mode and to map themes. The configured default applies even when the selector stays hidden.
  • profile.json accepts displayLabel and icon (both optional) — short label and icon of the profile selector. The existing label is unchanged.

  • Extension contracts are now public. Six types-only subpaths expose the interfaces a plugin must implement, and the same symbols are re-exported from the main entry point:

    ts
    import type { ICoreModule, IMapAdapter, GeoLeafEventMap } from "@geoleaf/core";
    // or, in long form:
    import type { ICoreModule } from "@geoleaf/core/contracts/core-module.contract.js";

    Published subpaths: core-module.contract.js (ICoreModule, ILifecycleModule, IUISlotModule, IModuleRegistry, IModuleUISlot), capability.contract.js (ICapabilityDeclaration, ICapabilityRegistry, ICapabilitySchema, …), config.contract.js (IGeoLeafConfig), map-adapter.contract.js (IMapAdapter plus the geometry types), layer-data.contract.js (LayerDataApi, LayerFeatureState), event-bus.contract.js (GeoLeafEventMap, GeoLeafRawEventMap, IEventBus). These modules are type-only: they declare only a types condition and emit no JavaScript — import type works, a value import is refused outright. PluginMetadata (the metadata of GeoLeaf.plugins.register) is also re-exported from the entry point.

    Until now these types were reachable through no channel at all: a plugin implementing ICoreModule had to redeclare it.

  • CapabilityRegistry — exported from @geoleaf/core and @geoleaf/core/kernel. The capability registry (register, isEnabled, isLoaded, ensureLoaded, getSchema, getAllSchemas) was complete but reachable from no ESM entry point: declaring a capability was only possible through GeoLeaf.plugins.registerCapability(decl), untyped, since ICapabilityDeclaration was not exposed either.

  • geoleaf:toolbar:action is typed — GeoLeaf.Events.on("geoleaf:toolbar:action", …) now gives detail: { action: string; element: HTMLElement }. This is the extension point through which a plugin reacts to a click on its toolbar button; until now it required a raw document.addEventListener with a hand-written cast.

    The event lives in a second map, GeoLeafRawEventMap, not in GeoLeafEventMap. The distinction is functional: the internal bus clones its payloads through JSON, which would destroy the element reference. Events.on / off / once accept keys from both maps; emission remains reserved for serialisable events.

  • GeoLeafHost (@geoleaf/host-runtime) describes six more members — GeoJSON, Utils, Log, Sync, Notifications, Layers. Until now they fell into the [key: string]: unknown tail, so GeoLeaf.GeoJSON.getLayerById(id) returned unknown. An additive change, no break.

  • GeoLeaf.Print.registerSlot(slot) (@geoleaf-plugins/print) — extension point for adding content to the composed canvas (cartouche, extra fields) at one of the title / legend / footer / overlay-tl|tr|bl|br placements. Re-registering the same id replaces the previous slot. The function and its ComposeSlot type already existed and the specification documented them as callable that way, but they were not mounted on the facade: GeoLeaf.Print.registerSlot was undefined at runtime, so no custom slot could be drawn. An additive change, no break.

  • GeoLeaf.Permalink.stopSync() — new public method that stops the URL synchronisation started by startSync() and detaches every listener it had attached (map moveend plus three document listeners). Useful for SPA hosts that recreate the map. startSync() has also become idempotent: a second call tears down the previous session instead of stacking listeners.

  • GeoLeaf.Notifications.show(message, typeOrOptions?, duration?) — the method was missing from the facade while the rendering engine, the historical GeoLeaf.UI.showNotification surface and the plugin documentation all named it. Calling it reached undefined and failed silently (the ?.show?.() guard swallowed the error, no toast appeared). It is an alias of notify().

Removed ​

  • BREAKING — the GeoLeaf.Filters (plural) namespace is removed, on both channels. The named export import { Filters } from "@geoleaf/core" and the GeoLeaf.Filters global disappear together, along with their single method filterRouteList(baseRoutes, filterState).

    Why. Its size did not condemn it, its name did: one letter separated it from GeoLeaf.Filter (singular), a different 8-member object that drives the filter panel and carries the permalink serialisation contract. The asymmetry made the trap all the surer — the typed one (Filter) was not on the root ESM entry, the untyped one (Filters) was. And filterRouteList had no caller: not in the core, not in the 13 plugins, not in the application, not in the profiles, not in the examples — nowhere outside its own definition.

    Migration. There is no direct replacement, and none is needed:

    • to filter the map, use the GeoLeaf.Filter capability (singular) — getActiveFilter(), applyFilter(state), reset() — unchanged;
    • to filter an array in your own code, use Array.prototype.filter. That is all filterRouteList did.

    Gone with it, for lack of a reachable reader: the route-filter engine (~320 lines) and the contribution seam through which the route capability injected it. The route capability itself — decorating route start and end points, driven by modules.route.* — is unchanged.

  • The catch-all @geoleaf/core/dist/* subpath is removed. It made any file under dist/ importable — internal chunks and implementation declarations included — which bypassed the whole set of subpaths defined next to it. If you were loading the stylesheet through @geoleaf/core/dist/geoleaf-main.min.css, use @geoleaf/core/style.css: that subpath already exists, designates exactly the same file, and is the one the documentation now uses. No other path under dist/ was documented.

Changed ​

  • BREAKING — two @geoleaf/core/capabilities/* subpaths change name. They remain published, under a name that describes what they do:

    BeforeAfter
    @geoleaf/core/capabilities/permalink/permalink-manager.js@geoleaf/core/capabilities/permalink/permalink-sync.js
    @geoleaf/core/capabilities/offline/core-config.js@geoleaf/core/capabilities/offline/config-seam.js

    Why. permalink-manager exported no PermalinkManager object — only four free functions for capturing and synchronising the URL; permalink-sync names them. core-config was one of three files with that name exporting three different functions, to the point that its own header had to warn "Not to be confused with capabilities/feature-info/utils/core-config.ts" — a note that patches an ambiguous name is the symptom of the defect, not its cure. config-seam joins the *-seam.ts family of guarded runtime accessors.

    Who is affected. Nobody, except a deep import that names these two paths explicitly. They appear in no README or example, and are re-exported by no main entry point. No other rename in this batch touches the published surface: the six <cap>-types.js files renamed to types.js are type-only modules whose .js was never emitted (their types remain resolvable under the new name).

  • BREAKING — the @geoleaf-plugins/storage plugin becomes @geoleaf-plugins/offline-ui. The npm package, the bundle (geoleaf-storage.plugin.js → geoleaf-offline-ui.plugin.js) and the runtime plugin id ("storage" → "offline-ui") change together.

    Why. The package no longer contained any storage. The offline engine — IndexedDB, cache, download, synchronisation — has been moved into the core (capabilities/offline) and the plugin only ships the interface: cache button, layer selector, synchronisation panel. Its own header already said so ("Entry point (offline UI) […] the offline engine is in-core"), and "storage" designated three things at once in the codebase: this plugin, the GeoLeaf.Storage facade and the engine. offline-ui pairs with capabilities/offline and makes the name true.

    Migration.

    • npm install @geoleaf-plugins/offline-ui (and uninstall the old one); import "@geoleaf-plugins/offline-ui";
    • <script> tag: dist/geoleaf-offline-ui.plugin.js;
    • GeoLeaf.plugins.isLoaded("storage") → isLoaded("offline-ui"), and likewise for the value returned by getLoadedPlugins();
    • if you target the button in the DOM or in tests: the attribute goes from data-gl-toolbar-action="storage" to data-gl-toolbar-action="offline-ui".

    What does NOT change — deliberately:

    • the GeoLeaf.Storage facade is unchanged: it belongs to the core, not to the plugin, and remains the entry point for offline support;
    • the i18n keys keep their storage.* prefix. That is a namespace distinct from the package identity, and above all a profile override surface: renaming it would break custom translations without clarifying anything;
    • the profile configuration is unchanged — it already goes through modules.offline.* and config/plugins/offline.json.
  • The interface language is remembered. Resolution order: ?lang= → localStorage['gl-lang'] → ui.language → fr. The URL parameter stays first, so that a shared link shows the same language to its recipient as to its author.

  • The active profile is remembered (localStorage['gl-profile']). sessionStorage['gl-selected-profile'] keeps its priority and its one-shot behaviour: the existing contract is not broken.

  • ICoreModule becomes a union — ILifecycleModule | IUISlotModule. The type declared dependencies, init and destroy as mandatory, whereas GeoLeaf.registry.register() has always accepted two shapes: a full lifecycle module, or a plain { id, ui } interface slot (what every plugin does when it adds a toolbar button without shipping start-up code). The contract described the first and rejected the second — published as such, it would have refused the eight real registration points in this repository.

    If you wrote class MyModule implements ICoreModule, write implements ILifecycleModule: TypeScript does not accept an implements clause on a union type. The name ICoreModule remains the one used by the register() parameter and has not changed role. No runtime behaviour change — it is the type joining the runtime, not the other way round.

  • exports now enumerates the ./facades/* and ./presets/* subpaths instead of announcing them through a glob. A glob promised everything dist/types/ contained, including modules that were never emitted (see Fixed). The field now lists the 15 facades actually shipped — branding, cluster, coordinates, featureinfo, filter, geolocation, labels, layers, legend, permalink, scale, share, sync, taxonomy, theme-toggle — and the 2 executable presets. @geoleaf/core/presets/preset.contract.js now declares only a types condition: it is a type-only module, import type works, a value import is now refused outright instead of resolving into the void.

  • The PWA install banners (Android and iOS) now display the configured application name — modules.pwa.short_name, otherwise modules.pwa.name (falling back to "GeoLeaf") — instead of a hard-coded "GeoLeaf", and all their labels are translated (fr/en/es/pt/it/de) through the core i18n dictionary. A profile that defines neither short_name nor name sees unchanged text.

  • Notifications.notify() / show() / success() / error() / warning() / info() now return the toast element (HTMLElement | null | undefined) instead of void. That is what Notifications.dismiss(toast) has always expected: without a return value there was no way to obtain the reference, and the documented example was inapplicable. A type widening — no existing call is broken.

Fixed ​

  • BREAKING — GeoLeaf.BaseLayers was undefined in production. The backwards-compatible alias of GeoLeaf.Baselayers was correctly mounted, then overwritten with undefined a few statements later. GeoLeaf.BaseLayers === GeoLeaf.Baselayers now returns true.

    Why it happened. The alias was written get BaseLayers() { return this.Baselayers; } inside the literal of an Object.assign. Object.assign reads the own properties of the source, so it invokes the getter — with this bound to the literal, which does not declare Baselayers. It wrote undefined, over the correct value set just before by the other half of the namespace assembly.

    Why nobody had seen it. The module carrying that getter was believed to be eliminated by tree-shaking — a comment had claimed so for several versions. It is not: it is present in the shipped bundle, and it runs last. And no internal check could detect it: they compare member names, and the BaseLayers key did exist — with undefined as its value.

    This fix is marked breaking out of caution: if your code tests GeoLeaf.BaseLayers to decide on a fallback to GeoLeaf.Baselayers, that fallback will no longer trigger. This is the behaviour expected all along; the value was the wrong part.

  • GeoLeaf.getMetrics() threw as soon as it was detached from the namespace. const { getMetrics } = GeoLeaf; getMetrics(); produced TypeError: Cannot read properties of undefined (reading 'getHealth'), because the shipped method was written this.getHealth(). It no longer depends on this: destructuring, passing it as a callback and map(GeoLeaf.getMetrics) all work. GeoLeaf.getHealth() was and remains correct.

  • The GeoLeaf namespace is typed at 81% instead of 31%. Forty-four public keys fell into the [key: string]: unknown tail of the ambient declaration: in TypeScript, GeoLeaf.init({...}) returned unknown and was not checked. The eleven capability facades (Branding, Cluster, Coordinates, FeatureInfo, Labels, NotificationSystem, PWA, Permalink, Scale, Share, ThemeToggle), the twelve kernel facades (API, BaseLayers/Baselayers, CONSTANTS, Errors, Events/events, Helpers, LayerManager, ThemeCache, Validators, version) and the twenty-one top-level methods (init, boot, setTheme, loadConfig, createMap, getMap, getAllMaps, getModule, hasModule, getNamespace, getHealth, getMetrics, fetch, get, post, bootInfo, mark, measure, getPerformanceReport, establishBaseline, notify) are now declared.

    The [key: string]: unknown tail stays in place: no existing access stops compiling. What changes is that accesses to the keys above are now checked — a call whose arguments were wrong and passed silently may now be reported by your compiler. That is the intended effect.

    Two clarifications about shapes the documentation presented differently:

    • GeoLeaf.init(options) requires map.target (or its target / mapId shorthand) and throws without it. The expected shape is { map: { target }, data: { activeProfile, profilesBasePath } }.
    • GeoLeaf.init() and GeoLeaf.boot() are not interchangeable: boot() starts the profile-driven application, init() is the manual wrapper around GeoLeaf.Core.init(). The start-up path never calls GeoLeaf.init().
  • registry.getModuleSchema() and getActiveModules() returned dependencies: undefined for every interface slot registered after start-up — that is, for every plugin loaded on demand. Their own return type (IModuleInfo.dependencies) nevertheless announces a non-optional array. Both now read dependencies ?? [], as the registry's topological sort already does.

  • Thirteen @geoleaf/core/facades/* subpaths compiled and then failed at import. The exports field announced ./facades/* through a glob whose types branch resolved 28 files and whose import branch resolved only 15: Rollup does not emit a module for a facade that is nothing but a re-export shell, and rightly so — it has no code. As a result, import { Core } from "@geoleaf/core/facades/core.js" passed type-checking then threw ERR_MODULE_NOT_FOUND at runtime. The affected ones were api, baselayers, constants, core, events, filters, helpers, introspection, layer-manager, pwa, storage, ui, validators. The corresponding symbols remained — and remain — reachable from the main entry point: import { Core, UI, Events } from "@geoleaf/core". ./facades/legend.js, the only subpath the documentation cited, was never affected.

  • @geoleaf-plugins/table appeared in no start-up report. The name "table" was still listed among the internal core modules excluded from reportPlugins(), a leftover from the time when Table was bundled. The plugin registered normally but stayed invisible in the boot console.

  • The plugins' TypeScript declarations were published but unreachable. Eleven packages (@geoleaf-plugins/cog, editor, file-import, flatgeobuf, geocoding, measure, print, realtime-layer, table, websocket and @geoleaf/connector) did ship their .d.ts files in the npm tarball but declared no types condition in their exports: an import "@geoleaf-plugins/table" from a TypeScript project failed with TS7016 ("Could not find a declaration file"), while the types were right there, next to it. The condition is now declared by all eleven. No API has changed — what changes is that your editor sees them.

  • @geoleaf-plugins/addpoi and @geoleaf-plugins/storage no longer ship the core's internal declarations. These two packages compile core sources, which made their declarations come out at a wrong root: the addpoi tarball contained 514 .d.ts files (2.6 MB), 483 of which belonged to @geoleaf/core, and its own entry point sat at a path no configuration could designate. These two packages now expose no types at all — they are consumed as side-effect bundles (import "@geoleaf-plugins/storage" mounts GeoLeaf.Storage), which is unchanged. Typing will return once their coupling to the core is untangled.

  • @geoleaf-plugins/print — the printed scale is at last the one that was locked. The map re-rendered off-screen covers the whole usable page; it was stretched into the map area, which any band (title, legend, footer) shrinks. As soon as a title was entered — the nominal case — the map was squashed vertically: 1:25,000 printed as 1:26,331 with a title, as 1:30,240 with title, legend and description, while the scale bar kept announcing the requested denominator. The capture is now cropped to the centre, pixel for pixel; computeBbox() accounts for the bands, so annotations carried over from @geoleaf-plugins/measure and the payload of the server fallback are placed correctly. This fixes the preview modal as well as exportImage() / exportPDF().

  • Permalink.startSync() no longer leaks listeners on re-initialisation (map recreation / SPA): the session is idempotent and its listeners are released by stopSync() / the internal reset.

  • The Android and iOS install banners no longer leak global listeners or pending timers between two initialisations.

  • A burst of error toasts no longer exceeds the maxVisible limit. When several notifications were queued and processed in one go, each error "freed" a slot by targeting a toast already being removed — an operation with no effect, but counted as a success. Toasts stacked beyond the configured limit (observed: 4 displayed for maxVisible: 2). Eviction now targets only a toast that can really be removed.

  • The coloured edge of toasts is at last displayed. The four types (success, error, warning, info) are meant to be distinguished by a coloured bar on the left: the base rule referenced a non-existent theme variable (--gl-accent instead of --gl-color-accent), which invalidated the declaration and cancelled the border style — the modifiers setting only a colour, no edge was rendered at all. The toast focus ring (:focus-within) was missing for the same reason, which also made it an accessibility defect.

  • In dark theme, the toast background follows the theme variables again. A second rule, of identical specificity and declared later in the stylesheet, imposed a fixed grey and defeated the computed background and dark shadow that the file nevertheless defines explicitly.

  • The form image viewer (@geoleaf/field-renderer, AddPOI plugin) no longer disappears after 150 ms. The core published a .gl-lightbox { opacity: 0 } rule inherited from an older viewer it no longer uses (its own is .gl-poi-lightbox-*), on a class that actually belongs to field-renderer. Since its opening animation has no animation-fill-mode, opacity fell back to that rule at the end of the animation: the image appeared, then faded out. The rule is removed.

  • When the filter panel opens, the bottom-left block (branding, scale) and the coordinate display shift again. Both offset rules were overridden by a margin reset applied with !important on the shared container.

  • The modules.permalink.fields allow-list is at last honoured by compact URLs. The permalink encodes state in base64 (#gl=…) when mode: "compact" is requested or, automatically, as soon as the verbose URL exceeds 200 characters — and that path ignored fields in both directions: when reading, a forged compact URL drove every facet (filter, theme, categories, tags, rating, layers), including those a profile had excluded; when writing, buildUrl() published the whole state although the verbose path had just filtered it. Both encodings now apply the same allow-list. No injection risk was involved (the values feed lookups over known lists), but fields did not keep its promise.

  • A view parameter that is present but empty no longer silently recentres the map on 0,0. #gl_lat=&gl_lng=2.35&gl_zoom=12 passed the presence check (the value is "", not absent), then Number("") was 0: the map opened off the Gulf of Guinea instead of ignoring an invalid permalink. An empty or blank value is now treated as absent — the URL is rejected and the profile view applies. An explicit gl_lat=0 obviously remains valid.

  • gl_rating is validated like the other numeric fields. It was the only one not going through the shared validator: #gl_rating=Infinity (or a 1e400 slipped into a compact payload, which JSON.parse turns into Infinity) was accepted as the minimum rating. Non-finite and negative values are now rejected.

  • A permalink containing non-Latin text no longer breaks the URL. The compact format (#gl=…) encoded to base64 through a routine that fails on any character beyond Latin-1: a filter in Japanese or Russian — or plain typographic ellipses — made the encoding fail. The failure was silent: URL synchronisation swallows the error, so the address simply stopped following the map, with no message. That path is moreover taken automatically as soon as the classic URL exceeds 200 characters, so without the profile requesting compact mode. Encoding now goes through UTF-8; compact links already shared remain readable, the old format being recognised and accepted on read.

  • A vector tile layer with a casing no longer leaves a residue on each rebuild. On a basemap or theme change, the casing sub-layer was not removed — the clean-up list targeted an id the builder does not produce. Each rebuild therefore left the previous one behind, and the source was released while a layer still referenced it.

  • Offline download progress no longer exceeds 100%. The emitted percentage was not clamped: a resource counted twice — a retry recorded both as a success and as a failure, an enumeration producing a duplicate — pushed the display beyond 100 (observed: 150% and 200%). The value is now clamped to [0, 100].

  • The offline synchronisation queue empties again. GeoLeaf.Storage.DB exposed neither updateSyncQueueStatus() nor removeSyncQueueEntry(), although the field collection plugins (AddPOI, Storage, Editor) call them to mark and then remove each replayed operation. Depending on the call site, the attempt threw a TypeError or did nothing at all — either way the queue was never purged, and already-synchronised operations stayed in it indefinitely. Both methods are now delegated to the synchronisation engine, like the four other queue operations that already were.

  • maxRetries: 0 no longer fails a download without attempting it. The obvious spelling of "do not retry" made the attempt loop unreachable: the operation was never called and the offline cache reported a failure with no cause. The field is renamed maxAttempts — it has always counted the total number of attempts, not the number of retries. maxRetries is still accepted as a deprecated, normalised alias, so no existing profile silently falls back to the default value.

  • The offline cache no longer grows indefinitely because of themes. Entries cached without a profile — which is the case for every theme — were stored outside the eviction index: they were neither counted in the budget nor ever deleted. A cache made only of themes declared itself empty and evicted nothing, whatever the configured quota.

  • Offline download no longer reports dangerous-scheme URLs, it skips them. Two fetch points (style resolution, extent computation) followed URLs coming from a remote style or TileJSON without any check; an entry using javascript:, data:, file: or blob: was reachable there. They are now refused before any network call, and enumeration continues with the following entries instead of stopping.

  • Finishing an offline download no longer fails in a DOM-less context (service worker, server rendering): emitting the progress event threw after every resource had been fetched, turning a successful operation into a failure.

  • Notifications no longer go mute after a map reset. When the notification capability was destroyed and then recreated (SPA host, profile reload), the engine came back disabled: notify() and its variants threw nothing, displayed nothing, and did not fall back to the console either — the kernel still believed it had a renderer, so messages were lost rather than degraded. A reset now brings back an active engine, and messages emitted in the meantime are queued and then delivered.

  • Layer labels react to zoom again after a basemap or theme change. When the map adapter was replaced without being destroyed first, the zoom subscription stayed attached to the old map: labels silently stopped recomputing until the page was reloaded.

  • Label buttons and the share button no longer survive the destruction of their capability. They stayed in the DOM after a destroy(); clicking a label button still called the destroyed module.

  • Permalink id lists are capped in length, not only in count. gl_layers / gl_shown / gl_cats / gl_tags already limited the number of items to 100, but a single item could weigh megabytes; each item is now truncated to 200 characters, like text fields.

  • The filter panel announced a label to screen readers that differed from the visible one. When a profile does not define modules.filter.title, the region announced "Filter" while its visible title showed "Filtrer" — an accessible name that does not match the visible label (WCAG 2.5.3). Both now come from a single source.

  • The filter panel is at last translated. Its title, its Apply/Reset buttons, its close button and its "no category" message were hard-coded French strings — while the corresponding translations already existed, complete, in the six dictionaries. An English- or German-speaking user saw French. Profile-defined labels still take precedence.

  • The details side panel and the layer manager speak the chosen language. Two side panel translation keys were missing from the six dictionaries, and three layer manager keys were missing in Spanish, Portuguese, Italian and German: those elements fell back to French whatever the configured language.

  • Four shipped profiles at last display the search label their author wrote. france-risques-inondation, france-urbanisme-btp, guyane-biodiversite and tourism set a specific prompt ("Rechercher une station…", "Rechercher un bâtiment, chantier…", "Rechercher un nom, une parcelle…", "Rechercher un POI…") in modules.filter.searchPlaceholder — the key nobody read — while leaving the generic "Rechercher..." in the one actually rendered. Those labels have been moved onto the search field concerned, where they also feed its accessible name.

  • The legend panel title is translated. Its default was the English string "Legend", served as such to all six languages inside an otherwise French interface. It now comes from the dictionary and follows ui.language. A profile that sets modules.legend.title still takes precedence.

  • GeoLeaf.Storage: a sanitiser that does not sanitise can no longer write. When a validator rejected a value and a sanitize function was supplied, the sanitised result was stored without being revalidated — so the validator became advisory as soon as a sanitiser existed. The result is now revalidated once, and refused if it is still invalid.

  • The min/max bounds of a validation schema are enforced. They were ignored, without a message, if the field did not also declare type: "number": a schema written { min: 5 } checked nothing at all.

  • JSON serialisation no longer returns undefined where it promises a string. For a value JSON cannot represent (undefined, a function, a symbol), JSON.stringify returns undefined instead of throwing — so the fallback was never taken, and the caller received undefined from a function declared to return a string.

  • The legend's default values are at last announced by introspection. modules.legend applied three defaults (title, position, collapsedByDefault) that its schema did not declare: a configuration tool could not display them. The default for title is the English string "Legend" while the rest of the interface is in French — the schema now states this explicitly instead of leaving it to be discovered in use.

  • Taxonomy configuration is at last visible to introspection. modules.taxonomy declared 1 key out of the ~19 it consumes: a configuration tool querying the schema saw neither the icons, nor the per-surface rendering options, nor the taxonomies themselves. The four sub-trees (icons, render, taxonomies, layers) are now declared with their real default values.

  • The runtime/build split of modules.pwa is documented in the schema. description, theme_color and background_color are read only when generating manifest.json — never at runtime — whereas name and short_name are read on both sides. Nothing said so: each key now carries the corresponding note.

Changed — BREAKING ​

  • modules.filter.searchPlaceholder is removed. The key was declared, typed and written by profiles, but no code ever read it: the filter panel has no global search field, only a text-kind fields[] descriptor carries a placeholder (fields[].placeholder), which is indeed rendered. No visible change: a map already showed the per-field placeholder.

    What this means for you: nothing at runtime — a JSON profile that keeps the key sees it ignored, exactly as before. In TypeScript, a configuration typed FilterConfig that mentions it no longer compiles; the replacement is fields[].placeholder on the search field concerned.

  • modules.permalink.fields no longer accepts "lat", "lng" or "zoom". These three values were inert: the view state is written unconditionally and required when parsing (a permalink without a view restores nothing), so removing them from the list changed nothing. Rather than let the type promise a granularity the runtime does not honour, the view is declared mandatory and taken out of the enumeration — fields now contains only what it actually governs: layers, shownLayers, filter, categories, tags, rating, theme.

    What this means for you: nothing at runtime — a JSON profile still listing "lat"/"lng"/"zoom" keeps working identically, those entries are simply ignored. In TypeScript, a configuration typed PermalinkConfig that mentions them no longer compiles: remove them. The view is always serialised, including with "fields": [].

Removed ​

  • GeoLeaf.UI.PanelBuilder documentation removed. That page described an API absent from the code: none of the documented functions (createPlainSection, renderText, renderTable…) exists any more, and the .gl-poi-panel__* CSS classes it presented were produced by nothing. The corresponding stylesheet is deleted along with it. Integrators composing their own detail panels should use GeoLeaf.FeatureInfo.

Internal ​

  • Stylesheets trimmed by ~24% (geoleaf-main.min.css: 127 → 97 kB). Three stylesheets of the feature-info capability were loaded twice — one being the exact concatenation of the other two — and roughly 850 lines of rules matched no element produced by the code. No appearance change expected; the documented public classes are unchanged.
  • maxPersistent is at last applied. The field was accepted and documented (@default 2) but init() never read it: the constructor value applied whatever happened, and configuring it changed nothing on screen.

Removed — BREAKING ​

  • The two plugin report methods are replaced by GeoLeaf.plugins.reportPlugins(). They partitioned the registry into two categories. There is only one now: every plugin is MIT and published on npmjs.

    The split was moreover wrong. Each report relied on a hard-coded list of names that overrode the type field declared by the plugin, and the two lists contradicted it in opposite directions: storage and editor each appeared in the list opposite to the one they declared. As a result, those two plugins were shown by both reports, and every start-up counted them twice.

    What this means for you: replace both calls with GeoLeaf.plugins.reportPlugins(). There is now a single report, without duplicates. A plugin whose healthCheck() fails is flagged on its line but no longer triggers a console.warn — one of the two old reports did, the other did not, and the second behaviour is the one kept: connector is legitimately not connected at boot, and warning on every page load would be a false alarm.

  • The type field of the plugins.register() manifest is removed. It served only the two reports above, and after their merge nothing read it any more. The Plugin Contract v1 (rule PC-03) no longer requires it.

    What this means for you: remove type from your plugins.register() call. Leaving it has no effect — unknown fields are ignored — but it will no longer be validated or read.

Changed ​

  • The start-up message now simply lists the loaded plugins (<plugins>, or open source if none). The old format split them into two groups based on a hard-coded prefix list (storage, addpoi) which omitted cog and ignored the type field: it mislabelled the plugins it knew and missed the one it did not.

  • The legend resolves the icon of a taxonomy category through the svgId field alone. It aligns with the rest of the taxonomy capability (map icons, badge pills) and with the shared resolver, which have never read anything but svgId. The inherited icon / iconId fields of a category entry are no longer read by the legend (they still were, as a fallback). No shipped profile was affected — all 9 declare svgId. What this means for you: if a custom profile set icon or iconId in its taxonomy.json categories for the legend, rename them to svgId (already the documented form).

  • modules.cluster.clusterRadius and modules.cluster.disableClusteringAtZoom at last drive clustering of GeoJSON point layers. These two keys were exposed by the introspection schema but never applied: the real radius of a GeoJSON layer stayed fixed at 80 px (max-zoom 14) whatever the value configured at profile level, the key being used internally only for a strategy comparison. Precedence is now per-layer override → modules.cluster.* → default. The schema is corrected accordingly: clusterRadius announces its real default 80 (introspection reported 50, which matched nothing applied) and disableClusteringAtZoom declares its default 14. What this means for you: if you were setting modules.cluster.clusterRadius / disableClusteringAtZoom expecting them to act, they now act — check the value you want. A profile that did not define them keeps exactly the current rendering (80 / 14). The shared POI cluster radius (50 px) is unchanged and stays independent of this key.

Added ​

  • GeoLeaf.Events at last exists at runtime. index.d.ts declared GeoLeaf.Events and the EVENTS_API.md page used it in 18 examples, but nothing mounted that casing: only GeoLeaf.events (lower-case) was set on the global. As a result, GeoLeaf.Events.on(...) compiled — the typings said so — then threw a TypeError at runtime.

    Both forms are now mounted and strictly equivalent (GeoLeaf.Events === GeoLeaf.events). Events is the canonical casing; events remains a permanent alias and is not deprecated, exactly like Baselayers / BaseLayers.

    What this means for you: nothing to do. If you followed the documentation, your code now works as written; if you worked around the problem by writing events, it keeps working identically.

  • GeoLeaf.Utils.wktToGeoJSON() at last exists. It had been announced in the changelog since v2 and documented as a member of the namespace, but was never set at runtime: it lived only on an object assembled by a module that became unreachable when the UMD builds were dropped (v2.0.0). Calling it threw a TypeError. It is now really mounted.

  • import { Utils } and window.GeoLeaf.Utils at last expose the same thing. The ESM export carried only 12 members where the global carried 27: the same name designated two objects of different shapes depending on how it was reached, and nothing stopped them drifting further. Both surfaces are now composed in the same place and locked by a test.

    They are still two distinct objects — the global must stay modifiable and re-applicable by the module lifecycle — but their members are identical.

    What this means for you: import { Utils } gives access to 16 members it did not carry (DOMSecurity, FetchHelper, ObjectUtils, ScaleUtils, TimerManager, createElement…). Nothing is removed.

  • GeoLeaf.Utils is at last typed. Its public interface was declared { [key: string]: unknown }: no completion, every member typed unknown (so Utils.debounce(fn) required a cast), and above all no gap between documentation and runtime was detectable at compile time. All 28 members are now declared.

  • IMapAdapter exposes getMarkerHandle(id). A new optional method of the adapter contract: it returns a typed handle (GeoLeafMarkerHandle — getLngLat() + on(event, cb)) on a marker created through createMarker(), or null. It exists for the two interactions that id-based management does not cover — reading a marker's position after the user has dragged it, and subscribing to its own events.

    What this means for you: nothing to do. The method is optional, so a custom adapter that does not implement it stays compliant. If you write an adapter for an engine with a per-marker event model, you can now expose it cleanly instead of forcing the caller around the contract.

Fixed ​

  • A proximity filter without a radius no longer empties the list. A proximity search enabled with a centre but without a radius rejected every route (the comparison used an undefined radius), instead of behaving like an absent criterion. A missing radius is now treated the way a missing centre already is: the criterion does not apply, and nothing is wrongly filtered out.

  • An empty tag selection no longer filters everything out. On route lists, enabling the tag filter without ticking any tag emptied the result, whereas the general filtering engine treats an empty selection as "no constraint" and lets everything through. Both paths now answer identically — an empty selection excludes nothing.

  • A title written variant: "title" is at last honoured in the side panel. A detail field declares itself a title in two ways, variant: "title" or style: "title", and the schema accepts both everywhere. The popup treated them equally; the side panel recognised only style. A field authored with variant therefore lost its required-field status there — it disappeared when its value was empty — as well as its category icon, while the same field displayed correctly in the popup. Both spellings are now equivalent on both surfaces. No rendering changes for existing profiles: the current authoring convention did not hit the defect.

  • Focus no longer escapes the image viewer with the keyboard. The lightbox focus trap recognised only buttons: a viewer containing a link (photo credit, source link) let Tab leave the dialogue. Links, input fields, drop-down lists and text areas are now taken into account. Same correction on the share modal, which conversely included disabled elements — Tab could get focus stuck there on an element unable to receive it.

  • Labels no longer disappear for good after a basemap change. When the map style is reloaded (basemap or theme change), removing a label layer can fail. On the zoom-triggered path, that error was not caught: it interrupted processing, and the layer was still considered "labels shown" — so it never rebuilt them again for the rest of the session.

  • The theme you choose at last survives a reload. Boot re-applied the profile theme by writing it into localStorage, overwriting the preference it had just read from it: no chosen theme held. Precedence is now explicit — stored user choice → profile ui.theme → prefers-color-scheme — and boot never writes any more. Only an explicit action (the theme button, or GeoLeaf.setTheme()) persists.

  • The button of a layer hidden by zoom at last switches it off. toggleLayer() decided from physical visibility, which zoom can force to "invisible", whereas the button reflects the logical state. On a layer you had enabled but which the current zoom hid, the click re-enabled it instead of switching it off.

  • Rapid basemap switching: no more stale result. Switching from a WMTS basemap to a raster one while the WMTS request was still in flight let the stale result apply over the new basemap. The request is now cancelled and its result discarded.

  • Contrast of the basemap buttons on hover. In light theme, the label dropped to a ratio of 1.10:1 on hover — that is, unreadable (WCAG AA requires 4.5:1). Corrected to 15.48:1. The dark theme was not affected.

  • The CSRF token degrades instead of crashing. Its automatic refresh was unguarded: if the cryptographic source disappeared after start-up, the timer threw an error every ~55 minutes, in a queue where nothing could catch it.

Changed ​

  • style.shape is restricted to "circle". The schema declared it as free text and the documentation announced "square" and "triangle" — neither has ever been rendered. Points are drawn by a MapLibre circle layer, which draws only circles. The key is moreover inert (no code reads it) and stays reserved. To differentiate categories, use styleRules or the taxonomy.

    What this means for you: a profile declaring "shape": "square" is now rejected by validation, instead of being accepted and then silently ignored.

  • Layer manager labels are translatable. "Gestionnaire de layers" (Franglais) becomes "Gestionnaire de couches" and goes, like "Fond de carte" and "Couches GeoJSON", through overridable i18n keys.

Removed ​

  • GeoLeaf.ensureMap(), GeoLeaf.requireMap(), GeoLeaf.hasMap() and GeoLeaf.Utils.MapHelpers are removed. They were declared in no typings, documented nowhere, and had no caller in the whole monorepo (plugins, demos, e2e, profiles).

    They were above all wrong: their duck-typing required setView, a Leaflet method that does not exist in the MapLibre API. GeoLeaf.ensureMap(myMapLibreMap) therefore returned null. They did not validate "is this a map?" but "is this the GeoLeaf adapter?", without saying so.

    The resolver to use is GeoLeaf.Utils.ensureMap() — the documented one, exported in ESM and actually called. It has taken over the validation this pair carried, minus the Leaflet requirement.

  • Three documentation pages described APIs that did not exist. They are corrected or removed. None was callable: copy-pasting their examples threw a TypeError.

    • GeoLeaf.Helpers.createElement() — removed in v3 (no caller, and its options shape silently diverged from the canonical factory: it read styles where the other reads style, and made innerHTML win over textContent). The documentation still presented it. Migration: GeoLeaf.Utils.createElement(tag, props, ...children), renaming styles → style.
    • GeoLeaf.Utils.escapeHtml() — was never mounted at runtime (same cause as wktToGeoJSON above). Use GeoLeaf.Security.escapeHtml(), which is mounted, tested and documented.
    • The AbstractRenderer page described a class and a source file deleted during the purge of Leaflet-era code. Page removed.

Changed ​

  • GeoLeaf.Utils.ensureMap() now validates its argument. It previously returned as-is any non-empty argument: ensureMap("foo") was "foo", although its documentation promises "the MapLibre GL map instance" and the example goes on with map.fitBounds(...). The failure therefore only appeared at the first method call, far from the cause.

    An argument that is not a map now gives null, like an absent map. The duck-typing covers getCenter / getBounds / on / off — present both on a GeoLeaf adapter and on a raw maplibregl.Map.

    What this means for you: nothing if you already tested the return value (if (map) …, the documented form). If you were routing something other than a map through this function, pass it directly.

  • Prototype pollution protection — single source. The __proto__ / constructor / prototype guards were copied into four modules, in four diverging forms, three of them silent. They are now all backed by a single canonical blocklist. Three writes reachable from the modules block of a profile (mergeModulesBag, mergeModuleBags, the Files.modules loader) were not guarded and now are; a module id equal to __proto__ in a profile can no longer reparent the configuration bag. No API change — legitimate identifiers are unaffected.

Fixed ​

  • GeoLeaf.Filters.filterRouteList() — the proximity radius was sometimes kilometres, sometimes metres. The function resolved its distance through GeoLeaf.Utils.getDistance ?? haversine. Both satisfy the same signature (lat1, lng1, lat2, lng2) => number, but the first returns kilometres and the second metres: the radius was therefore interpreted with a factor of 1000 depending on whether GeoLeaf.Utils had been loaded. In a full bundle the kilometre branch won; the test suite, however, never set GeoLeaf.Utils, so it had always validated the metre branch — hence a defect that stayed invisible.

    proximity.radius is now in metres, unconditionally, consistent with the filter engine predicate and with the radiusKm * 1000 conversion already done by the panel and the permalink serialiser. The unit is now documented in the typings (FilterStateInput.proximity), which it was nowhere.

    What this means for you: if you called filterRouteList() with an active proximity and compensated for the kilometre behaviour, divide your radius by 1000. The category, sub-category, tag, rating and search filters are unchanged, and the built-in panel's proximity filtering was not affected (it already went through the engine, correct all along).

  • Public typings — GeoLeaf.Errors described signatures that were not those of the code. Six discrepancies, all corrected in index.d.ts without touching the runtime:

    • createError() was declared (message, code?, context?) whereas its first argument is the error class: createError(Errors.ValidationError, "message"). TypeScript code written from the typings passed a message where the runtime expects a constructor.
    • safeErrorHandler() was declared (error, handler) — the runtime expects (handler, error).
    • GeoLeafError.timestamp was typed number; it is an ISO-8601 string.
    • The constructor was declared (message, code?, context?); it is (message, context?) — a three-argument call compiled and filed the code into the context.
    • code was declared required although the base class never assigns it.
    • normalizeError() omitted its second parameter defaultMessage.
  • Public typings — GeoLeaf.Helpers declared four methods that do not exist. debounce, throttle, fetchWithTimeout and batchDomOperations appeared in HelpersAPI but have never been present on the runtime object: calling them compiled and then threw TypeError. Same defect as _UIComponents.clearElement() / createEmptyMessage(), fixed in v3.0.0. Declarations removed.

    Migration: debounce and throttle do exist — on GeoLeaf.Utils, not on Helpers. fetchWithTimeout has no equivalent: use Helpers.createAbortController(timeout) with fetch. Nor does batchDomOperations: Helpers.createFragment() covers the case.

    Conversely, six genuinely real methods were missing from the typings and are now declared: applyCssText, lazyLoadImage, lazyExecute, addEventListener, addEventListeners, delegateEvent. No runtime change: GeoLeaf.Helpers exposes the same 23 members as before.

  • GeoLeaf.Core.setTheme() / getTheme() were out of sync with the theme engine. Both methods kept their own internal state, never updated by the canonical engine (GeoLeaf.UI) — the one driven by the theme button, by GeoLeaf.setTheme() and by the boot sequence. Observable consequences:

    • Core.getTheme() returned "light" on a dark-themed page from the very first frame, and stayed wrong after any change made outside Core.setTheme().
    • Core.setTheme() wrote only the document.body class: no localStorage persistence, no class on the #geoleaf-map container (wrong theme in full screen), no aria-pressed update on the theme button (an accessibility defect), no geoleaf:ui-theme-changed emission.

    Both methods now delegate to the canonical engine when GeoLeaf.UI is present, and fall back to the body class otherwise. GeoLeaf.Core.setTheme(), GeoLeaf.setTheme() and GeoLeaf.UI.applyTheme() are therefore really interchangeable — which the documentation already claimed without it being true.

    No signature or surface change. The only behaviour modified is the one that was wrong: getTheme() now returns the theme actually applied. The warning message for an invalid value becomes [GeoLeaf.Core] setTheme() ignored an invalid theme: {value}.

    The chosen theme still does not survive a reload, for a distinct and pre-existing reason: the boot sequence re-applies the initialisation theme, overwriting the stored value. setTheme() now does write localStorage, but boot comes after it. This point is tracked separately and is not changed by this version.

  • GeoLeaf.Storage.OfflineDetector — three defects. (1) The abort timer of the connectivity ping was cancelled only on the nominal path: each failed ping left a 5 s timer alive, accumulating for as long as the network stayed down. (2) The initial state was read from navigator.onLine at module import, which threw in any environment without navigator (SSR, Node test without jsdom); the read now happens in init(), under a guard. (3) init() is now idempotent: a second call tears down the previous one instead of stacking a second set of window listeners.

Removed ​

  • BREAKING — GeoLeaf.Bus and GeoLeaf.Utils.createEventBus() removed. An in-memory pub/sub, mounted at boot and never read: no read in the library, the profiles, the integration tests or the plugins, and it was declared neither in index.d.ts nor in the ambient typings. It moreover shared the file name of the real core event bus, which produced two mistaken diagnoses in review.

    Migration. The real event system is unchanged and remains the only supported route: GeoLeaf.Events.on(name, handler) / .off() / .once() for listening (26 events documented in EVENTS_API.md). If you needed a generic application bus, the browser's EventTarget covers the case in three lines — this module added nothing beyond it.

  • BREAKING — GeoLeaf.DOMSecurity.createElement() removed. Despite its namespace, this function sanitised nothing: it did not call setSafeHTML, did not handle innerHTML, and wrote unknown attributes through element[key] = value without any guard. Its name promised a protection it did not provide — all the more serious a trap since the plugin contract explicitly directs authors to GeoLeaf.DOMSecurity.* for sensitive DOM operations. It had no caller.

    Migration. Use GeoLeaf.Utils.createElement(tag, props, ...children): it guards property writes and routes innerHTML through DOMSecurity.setSafeHTML(). The rest of GeoLeaf.DOMSecurity (setSafeHTML, setTextContent, clearElement, clearElementFast, createSVGIcon, getIcon, SVG_ICONS) is unchanged and remains the recommended route.

  • BREAKING — GeoLeaf.Helpers.createElement() removed. No caller, and its options shape silently diverged from the canonical factory: it read styles where the other reads style, and made innerHTML win over textContent (the reverse precedence). Since both interfaces carried an index signature, no type check would have flagged a substitution.

    Migration. GeoLeaf.Utils.createElement() — take care to rename styles to style if you passed a style object. The rest of GeoLeaf.Helpers is unchanged.

Security ​

  • Hardening — data: URLs are now validated against the same allow-list everywhere. GeoLeaf.Validators.validateUrl() tested the image/ prefix, accepting any sub-type (data:image/bmp, data:image/x-anything-at-all), where GeoLeaf.Security.validateUrl() applied an exact allow-list of six types (png, jpeg, jpg, gif, svg+xml, webp). The same URL therefore received opposite verdicts depending on the entry point. Both functions now share the allow-list and the same MIME type parser.

    Observable effect. GeoLeaf.Validators.validateUrl("data:image/bmp;base64,…") now returns { valid: false } instead of { valid: true }. The six allowed types are unchanged. Signatures, return shapes and error messages do not change.

    Fixed along the way: MIME type extraction returned image/png;base64 instead of image/png (invisible against a prefix test, blocking against an exact allow-list).

Fixed ​

  • GeoLeaf.Validators.validateUrl() returned an invented domain for relative URLs. The function resolved against a hard-coded base (http://dummy.com), so validateUrl("/api/data.json") returned { valid: true, url: "http://dummy.com/api/data.json" }. It now resolves against the current origin, like GeoLeaf.Security.validateUrl().

  • Public typings — the ValidatorsAPI interface was unusable. index.d.ts declared all eight methods (validateUrl, validateCoordinates, validateEmail, validatePhone, validateZoom, validateRequiredFields, validateGeoJSON, validateColor) with a void return type, while all of them return a { valid, error } object. Any TypeScript code writing if (GeoLeaf.Validators.validateUrl(u).valid) failed to compile. The declared signatures now match the implementations, options included (ValidatorOptions, ValidateUrlOptions, ValidateZoomOptions, ValidationOutcome are exported).

  • BREAKING — GeoLeaf._UIDomUtils removed from the namespace. This internal module (_ prefix) exposed only two members, both with no caller in the library, the profiles or the integration tests — but both documented with examples in GeoLeaf_UI_Components_README.md, published on npm. It is that page, not the code, that made it a contract: the removal is therefore treated as a breaking change, just as attachAccordionBehavior() was in its time.

    Migration.

    • GeoLeaf._UIDomUtils.resolveField(obj, path) was only an alias of the canonical internal helper; it never had a public equivalent. An integrator using it to read a nested property can replace it with a dependency-free line: const at = (o, p) => p.split(".").reduce((v, k) => (v == null ? undefined : v[k]), o);
    • GeoLeaf._UIDomUtils.getActiveProfileConfig() already delegated to GeoLeaf.Config.getActiveProfile() — call that one directly, it is public and unchanged.

    GeoLeaf.UI._getActiveProfileConfig(), the legacy facade wrapping the second, is removed in the same move (same absence of caller, same migration path).

  • Documentation corrected — GeoLeaf._UIComponents.clearElement() and createEmptyMessage() never existed. Both were described with a copy-pasteable example in GeoLeaf_UI_Components_README.md while neither is implemented: calling them threw a TypeError. Sections removed. (GeoLeaf.Utils.DOMSecurity.clearElement() does exist and is quite real — but that is another namespace.)

  • BREAKING — GeoLeaf.LayerManager: removal of updateSections(), addSection(), toggleCollapse() and isCollapsed(). These four methods were documented but absent from the typings (index.d.ts never declared anything but init() and refresh()), and none had a caller — not in the library, not in the integration tests, not in the profiles. toggleCollapse() was moreover broken after a Core.destroy(): it dereferenced _container, reset to null on teardown.

    Migration. Collapsing the panel is still driven by its header button (no action required). Sections are declared in the JSON profile (layerManagerConfig.sections) — that is the nominal path, and it was already the only one used. Adding a section dynamically has no programmatic replacement: if you had a real use for it, open an issue and the method will be reintroduced with typings and end-to-end coverage.

    GeoLeaf.LayerManager.init() and refresh() are unchanged.

  • GeoLeaf._LayerManagerShared removed from the namespace. An internal key (_ prefix, outside the documented public API) exposing a state object with no reader: the layer manager's real state lives in the module itself. Its reset() therefore cleaned nothing, and the panel survived a destroy → recreate cycle. Fixed: teardown now acts on the real state.

Fixed ​

  • The layer manager no longer survives a Core.destroy() → Core.init() cycle. The map, the control and the accumulated sections persisted from one instance to the next, and a pending deferred refresh could fire on a detached DOM.

Security ​

  • Feature URL validation now applies a protocol allow-list. The link, photo and url properties of a GeoJSON feature were checked by a plain new URL() in a try/catch — and new URL("javascript:alert(1)") succeeds. As a result, a value using javascript:, vbscript:, data:text/html or file:// was considered valid and raised no warning. It was the library's only URL check without an allow-list; it now delegates to the canonical security validator (http:, https:, data: images).

    Exact scope: this is a validation warning, not an injection flaw. Those values did not reach the DOM through this path — rendering (renderLink / renderImage) already validated the URL at the sink and displayed nothing for a disallowed protocol. The defect is that feature validation kept quiet about a malicious profile instead of reporting it.

    What this means for you: new console warnings if your data contains these protocols. Relative paths (/img/a.png, ./a.png, ../a.png), protocol-relative URLs (//host/a.png), data: images and contact schemes (mailto:, tel:) are still accepted identically. Three cases now warn where they passed before: ftp://, blob: and data:image/* outside the MIME allow-list. All stay at warning severity — no feature is invalidated, nothing renders differently.

  • Prototype pollution hardening of the configuration write path. Config.Storage.setValueByPath() now refuses any path segment named __proto__, constructor or prototype — including the last segment (a single-segment path bypassed the guard). The same check is applied to GeoLeaf.Utils.setNestedValue(), which had none. The other config writers (set, merge, deepMerge) were already protected.

    Concrete impact: a profile mapping.json whose mapping key targeted a prototype could graft an inherited property onto every normalised POI, which then propagated to feature properties, popups and table columns. Global pollution of Object.prototype was not reachable — the scope was limited to the POI objects being built. No shipped profile was affected.

    Not breaking for legitimate configurations: only paths targeting a prototype are refused, and the write becomes a no-op with a warning. If you used GeoLeaf.Utils.setNestedValue() with such paths, the call no longer modifies the object.

    Security documentation (docs/SECURITY.md, docs/security/SECURITY_CONTRACT.md) rewritten and checked against the code: it attributed several vectors to a non-existent function.

Fixed ​

  • Two French aria-label values contained leftover Franglais ("Afficher / hide la layer" and "Afficher/hide les étiquettes"). Corrected to idiomatic French ("Afficher / masquer la couche" and "Afficher/masquer les étiquettes"). Only the default French dictionary was affected — the five other locales were already correct, and both the i18n keys (aria.layer.toggle, aria.labels.toggle) and the getLabel() API are unchanged.

Fixed ​

  • The AddPOI form showed NO category or sub-category field — on every profile. The plugin looked for the taxonomy in a taxonomy key at the root of the active profile, which the profile loader has never produced: the read therefore always returned "nothing", the form builder concluded "this profile has no taxonomy" and added neither the category list nor the sub-category one. Silently: no error, no warning. The taxonomy is bound to the layer (modules.taxonomy.layers.<id>.use): the form now resolves it from the layer on which the POI is created or edited, and reloads it when you change layer in the selector.
  • A profile's icon sprite was NEVER cached for offline use — on every profile. The resource enumerator looked for spriteUrl at the root of the profile, where it lived before the taxonomy v3 rework; since then it is declared in the capability's configuration file (config/plugins/taxonomy.json). The config file was downloaded — but not the SVG it points to, so icons were missing once offline. Resolution now goes through the Files manifest, as it does for layers, and the URL is requested exactly as the engine requests it online (a rewritten URL would have been stored under a key that is never looked up). A disabled taxonomy capability still downloads nothing.
  • A style without an id made its layer fail to load — the layer never appeared. The schema stopped requiring id (the file name serves instead, the case for roughly 20% of style files), but the runtime validator still required it: the style was rejected, the loader threw, and the layer was never created. No message pointed at the real cause. 15 styles across 3 demonstration profiles were in that state. The validator is aligned with the schema, and the loader now derives the id from the file name — the derivation the schema documented without anyone implementing it. An explicitly declared id still wins, and its format is still validated.
  • A map.center written [lng, lat] passed without a word. center is [lat, lng], like bounds. Three profiles had it inverted ([-53, 4] placed French Guiana in the Southern Ocean), with no visible effect as long as they also declared bounds — which the loader prefers. Corrected, and the profile loader now warns when a centre falls outside its own bounds but falls inside once swapped, or when its latitude leaves [-90; 90]. A simple range check was not enough: -53 is a perfectly valid latitude.
  • A layer declaring a zoom threshold in zoomConfig.minZoom/maxZoom was invisible at ALL zoom levels. The field was called minZoom/maxZoom but the engine read its content as a scale denominator (the X in 1:X): a minZoom: 6 was therefore understood as "scale 1:6", that is a zoom of about 27 — out of reach (MapLibre caps at 24). The layer never appeared; ticked manually in the layer manager, it appeared but then respected no threshold at all. 18 layers across 3 demonstration profiles were affected. The field is renamed scaleConfig.minScale/maxScale, whose name states the unit, and the validator now rejects any value <= 24 as well as the old zoomConfig block (see Breaking Changes below). A profile whose thresholds were already denominators was not affected and keeps its values as they are.
  • import { Config } from "@geoleaf/core" shipped a Config WITHOUT .get(), .set(), .getAll(), .loadUrl() or .getSection(). The dist/esm/ build — the artefact exports["."] resolves, therefore what every bundler consumer (Vite, webpack, Rollup) receives — pruned the three modules that put these methods on the Config singleton. The CDN bundle (dist/geoleaf.esm.js, <script type="module">, unpkg/jsdelivr) was never affected: if you use it, there is nothing to do. If you import @geoleaf/core from a bundler, update: this is a fix, not an API change.
  • package.json#sideEffects protected nothing. All its entries targeted src/**/*.ts — a directory files does not publish. For your bundler, the whole package was declared side-effect free, which allowed it to drop the modules that populate window.GeoLeaf.*. Rewritten against the published paths, and now derived from the code and checked at every build.

Added ​

  • GeoLeaf.Taxonomy.getLayerCategories(layerId): Record<string, TaxonomyCategory> — the categories bound to one layer, resolving modules.taxonomy.layers.<id>.use for you. Returns {} if the layer has no binding, if the binding names an unknown taxonomy, or if the capability is disabled (enabled: false).

    • Prefer it to getCategories(ref) as soon as you start from a layer: getCategories expects the name of a taxonomy, which only the layers table knows. Reading it yourself amounts to reimplementing binding resolution — which cost the AddPOI form its category lists, empty on every profile without a single message (fixed in this version, see Fixed).
  • GeoLeaf.PWA.isInstallable(): boolean — to display your own install button instead of the built-in banner. It follows the same routing as the banner: on iOS Safari, true if the app runs on iOS without already being installed (iOS never emits beforeinstallprompt, it is the only available signal); on Android/Chrome/Edge, true once the browser has offered a deferred install prompt.

    • On Android, the answer means "a prompt is available", not "this browser could install the app": the deferred prompt is only captured if installPrompt.enabled is true. Banner disabled ⇒ false even on an installable Chrome. iOS is not concerned.
    • Note: the internal specification announced this method (as well as prompt(), dismiss() and getInstallState()) since v2.1.0, while none was exposed. Only isInstallable() had an implementation; it is now wired, the other three never existed and are removed from the internal documentation.
  • Stable exports subpaths, to compose your own entry point and ship only what you list:

    subpathcontents
    @geoleaf/core/kernelthe kernel facades (Core, Config, UI, LayerManager, Events, …)
    @geoleaf/core/globalsside effect: populates window.GeoLeaf.* and pulls the kernel sheet
    @geoleaf/core/helpersside effect: wires GeoLeaf._app
    @geoleaf/core/bootinstallBoot(manifest)
    @geoleaf/core/capabilities/<id>/install.jsthe installer of one capability (LEGEND_INSTALLER, …)
    @geoleaf/core/facades/<name>.jsLegend, Permalink, Share
    @geoleaf/core/presets/manifest.full.jsthe manifest of the 18 shipped capabilities

    All typed. See COOKBOOK.md, Recipe 8.

  • The CSS follows the code. Each capability imports its stylesheet from its install.ts: the CSS is a node of the module graph and tree-shakes with the capability. An entry point without filter ships neither its JS nor the CSS of its proximity bar (measured: −19% CSS on the 9-capability example).

  • Explicit cascade — @layer gl.reset, gl.tokens, gl.kernel, gl.capabilities, gl.overrides. The order no longer depends on how your bundler concatenates. gl.overrides is reserved for you: a rule you place there wins without !important and without a specificity war.

Changed ​

  • GeoLeaf.registry.getAll() and introspection list 6 kernel modules instead of 8 — security and api no longer appear. Nothing is removed from the public API: GeoLeaf.Security, GeoLeaf.CSRFToken, GeoLeaf.DOMSecurity, GeoLeaf.API.* and the other facades are unchanged, and are even available earlier (see below). Those two entries were only lifecycle wrappers around subsystems that have neither a map nor a configuration to wait for: their init()/destroy() were empty. You are only affected if you enumerate the registry's modules (diagnostics, tooling) — not if you call their facades.
  • The kernel facades are set at bundle import time, before GeoLeaf.boot(). The surface available from import goes from 64 to 88 keys: GeoLeaf.GeoJSON, GeoLeaf.ThemeCache, the _LayerManager* / _UI* / _Theme* helpers, _OfflineDetector, _StyleUtils… No key has disappeared. Concretely, a plugin or script loaded before boot() can again call GeoLeaf.I18n.registerDict(), GeoLeaf.notify() or GeoLeaf.Utils.* at its own top level without the call being silently lost. This restores a behaviour that an internal rework (v2.x) had removed without documenting it — and whose loss was mute: the plugin still mounted, only its labels disappeared.
  • Switching raster basemap no longer recreates the source when only the tiles change. The old switch destroyed the source and the layer to rebuild them identically; it now mutates the URLs in place (setTiles), which avoids flickering. The previous behaviour is still used as soon as anything else changes (tile size, attribution, zoom bounds): those properties are frozen at source creation and a mutation would silently lose them.
  • The coordinate display is written at most once per frame. The cursor emits far more events than the screen displays frames: intermediate writes were invisible by construction. The displayed position remains the last known one — the reading does not freeze on a stale position.

Added ​

  • A warning when a layer displays many points without clustering (beyond 1000). A heavy profile degraded the browser without the slightest signal. The message names the layer, its number of points and the option to enable. It is a warning only: rendering is not modified, clustering is not forced — the decision is yours.

Changed ​

  • A layer's scale window is now carried by the engine, and it is the law. scaleConfig.minScale/maxScale are converted into MapLibre minzoom/maxzoom and set on every sub-layer: the layer appears and disappears during the zoom, instead of waiting for the end of the gesture. Two visible consequences:

    • Ticking a layer outside its scale range no longer displays it. Previously, a click in the layer manager overrode the threshold and the layer stayed visible at every zoom. That workaround is what hid the zoomConfig bug fixed above for three months. The behaviour is now that of a GIS: a layer has a range, and the range wins.
    • Clustered layers at last respect their range: cluster bubbles were the only sub-layers not receiving the bounds, and kept displaying outside the window.
    • Since the conversion depends on latitude (the same 1:X does not fall at the same zoom in French Guiana and in Norway), the bounds are recomputed when the map moves far enough in latitude. Zooming triggers no recomputation.
  • BREAKING — the "compose your own entry point" recipe has changed paths. The ones the COOKBOOK displayed (@geoleaf/core/src/…) never worked: src/ is not published. Use the subpaths in the table above.

  • BREAKING — GeoLeaf._loadModule() and GeoLeaf._loadAllSecondaryModules() are REMOVED, and nothing replaces them. The whole lazy-loading machinery disappears (src/lazy/, the dispatcher, _app._ensureModule). Migration: delete the call. What these functions went to fetch is already in the bundle by the time your script runs — they answered a build question at runtime.

  • @geoleaf/core/style.css (→ dist/geoleaf-main.min.css): path, name and content unchanged. The file is now produced by Rollup instead of postcss-cli, but it still contains the kernel and the 18 capabilities. Nothing to change if you load it through <link>.

    • They no longer kept their promise anyway. Every in-core capability anchors itself through its install.ts since v3: its code is in the eager closure. The chunks still served were therefore re-export shells over code already present — Rollup emitted them empty (Generated empty chunks), and the browser downloaded them all the same on every page load. Boot made an asynchronous round trip for nothing.
    • The module name table in COOKBOOK.md was already wrong 7 times out of 9 (poi, poiCore, poiExtras, legend, labels, themes, search no longer existed): a call with any of those names already fell into a console.warn("Unknown module").
    • If what you wanted was a SMALLER bundle (rather than a deferred one), that is now a build choice, and it is supported: compose your own entry point from the capability installers you need — the rest is tree-shaken, not deferred: absent. Full, tested recipe: examples/minimal/entry.ts (9 capabilities out of 18, −15% boot payload, measured at every build). See COOKBOOK.md — Recipe 8: Shipping less than the whole library.
    • A configuration flag (modules.<id>.enabled) disables a capability; it cannot remove its code from the file the browser has downloaded. Only the build choice can.
  • BREAKING — taxonomy v3: the capability now owns the POINT SYMBOL, and nothing else. It owns the icon, its colour, the badge disc (fill / border) and the colour of the category / sub-category pill badges on the feature-info surfaces. The geometry colour (polygon fill, polyline stroke, and business colour of points) as well as the point size go back to each layer's styleRules.

    • What disappears from the configuration: categories.<val>.colorFill, .colorStroke, .color, .colorRoute. These keys were documented as driving fill-color / line-color per category — they painted nothing: the module that read them was never registered (its gate read a profile key loaded after the gate was evaluated). Migration: express geometry colour in the layer's styleRules (when.field accepts any attribute, including properties.categoryId).
    • modules.taxonomy.enabled becomes OPT-OUT (default true) and really gates everything: false switches off map icons, the badge disc, the pills, legend icons and per-category filter options. Until now the key was opt-in and disabled nothing — setting it to false had no observable effect. A profile relying on that non-effect must now remove the key.
    • Composition rule: taxonomy replaces the default value of the point paint; the layer's styleRules keep priority. Cascade: styleRules > sub-category > category > layer default.
  • BREAKING — removal of GeoLeaf.Helpers.StyleResolver, GeoLeaf.Helpers.getColorsFromLayerStyle() and GeoLeaf.Helpers.resolvePoiColors(). These three helpers resolved a POI colour from a layer's styleRules, but hard-coded the column names properties.categoryId / properties.subCategoryId and had no caller in the core. styleRules resolution is handled by the adapter's style converter, which accepts any field. No replacement API — these functions had no documented use.

  • BREAKING — removal of GeoLeaf.UI.hasActiveFilters(), GeoLeaf.UI.getActiveFilters() and GeoLeaf.UI.resetAllFilters(). These three methods already answered nothing useful: they read an internal state (_UIFilterStateManager) whose only writer was conditioned on a profile key, filters at the root, that no profile has ever declared — not even the reference profile. In practice hasActiveFilters() therefore always returned false, getActiveFilters() always [], and resetAllFilters() reset nothing. It is the last remnant of the pre-capability filter UI.

    • Migration: use the filter capability, which reads the real panel — GeoLeaf.Filter.hasActiveFilters(), GeoLeaf.Filter.getActiveFilter(), GeoLeaf.Filter.reset(). If your code called the GeoLeaf.UI.* versions, it already received a constant answer: the replacement fixes a behaviour, it does not reproduce it.
    • The corresponding configuration schema (profile.filters) no longer exists either: filters are declared under modules.filter (a profile's config/plugins/filter.json file).

Added ​

  • Point symbol — iconColor + marker (non-breaking, opt-in): a category (or sub-category) now accepts iconColor (glyph tint; absent ⇒ white, the historical rendering) and marker — either { fill, stroke, strokeWidth } for a disc under the icon, or false for a bare icon (no fill, no border). Absent ⇒ taxonomy overrides nothing and the layer style keeps control. No radius: point size belongs to the layer (the same category serves layers with different radii). Tinted icons are rasterised and registered as distinct MapLibre images — a profile that declares no colour keeps byte-identical symbol identifiers.
  • modules.taxonomy.render.<surface>.colorBadges (non-breaking, opt-in, default false): colours the category / sub-category pill badges of the surface (popup / tooltip / sidepanel) with the colours of the category marker — the pill and the symbol on the map read as one and the same object. New facade method GeoLeaf.Taxonomy.resolveBadgeStyle(layerId, feature, surface, field) (taxonomy decides, feature-info places the DOM).
  • modules.taxonomy.icons.iconSize (non-breaking, default 0.5): MapLibre icon-size of the symbol sub-layer. The default is exactly the value hard-coded until now.
  • GeoLeaf.Taxonomy.getIconVariants() and .resolveMarkerPaint(layerId, paint) (non-breaking): consumed by the MapLibre adapter to register tinted icons and to compose the disc paint, respectively.

Fixed ​

  • The pill badges were unreadable. An unscoped CSS rule in the side panel stylesheet overrode the popup one and imposed near-white text with no background at all, on both surfaces. The coloured variants existed in the stylesheet but were never emitted, and their two colour sets were swapped. Pills now have a background, a verified contrast, and the gl-poi-badge--category / --subcategory classes.

  • The offline synchronisation badge disappeared when the theme changed. The restyling path rebuilt the point paint without reapplying the synchronisation decoration. It is now reapplied, as it is at layer creation.

  • Sprite icons defined by alias (<use href="#…">): they showed in popups but stayed invisible on the map (the rasteriser did not follow the reference). The alias is now resolved.

  • GeoLeaf.Taxonomy.getIcons() + modules.taxonomy.icons.showOnMap key (non-breaking): the GeoLeaf.Taxonomy facade now exposes getIcons() (returns the modules.taxonomy.icons block — spriteUrl / symbolPrefix / defaultIcon / showOnMap — or null), the single source of the icon configuration read by the POI sprite injector and the legend (in-core successor of the former GeoLeaf.Config.getIconsConfig(), removed — see §Removed). New optional key modules.taxonomy.icons.showOnMap (boolean, default "on" when absent): the gate for displaying category icons in the legend (parity with the former legacy gate).

  • Category icon next to the title in popups / tooltips / side panels — modules.taxonomy.render (non-breaking, opt-in): new configuration block modules.taxonomy.render.{popup,tooltip,sidepanel}.{showIconCategory,showIconSubcategory} (6 boolean flags, defaults false) displaying the POI icon next to the title of the info surfaces (behaviour restored after the POI dissolution), symmetrically with showIconsOnMap (icons on the map). The icon appears when the taxonomy is enabled, the layer is bound (modules.taxonomy.layers.<id>.use), a flag of the surface is true, and an icon resolves — priority sub-category → category → default icon. New facade methods GeoLeaf.Taxonomy.resolveTitleIcon(layerId, feature, surface) and GeoLeaf.Taxonomy.ensureSprite() (the taxonomy capability resolves, the feature-info capability injects the sprite's <use> glyph, CSP-safe). Behaviour is byte-identical as long as no profile enables a flag.

  • Clustering — in-core capability GeoLeaf.Cluster + modules.cluster (internal reclassification, additive facade): point clustering (POI + GeoJSON point layers, native MapLibre cluster:true) is now a declared built-in capability (cluster), introspectable through GeoLeaf.Introspection.getCapabilitySchema("cluster"), active by default (opt-out through modules.cluster.enabled: false). New read-only facade GeoLeaf.Cluster (isEnabled(), getConfig()). Clustering is native end to end (no external supercluster dependency); rendering and behaviour are unchanged.

  • Geometry interaction events (non-breaking): two new DOM events geoleaf:feature:click and geoleaf:feature:hover ({ layerId, featureId, properties, lngLat, point, zIndex }) emitted by interactive GeoJSON and vector-tile layers on click or hover. These events replace the internal popup/tooltip binders and let an external capability (for example @geoleaf-plugins/feature-info) react to geometry interactions without coupling to the core. POI behaviour (cursor, popup, side panel) is unchanged; POI keeps emitting geoleaf:poi:click on its own channel.

  • Per-layer styling plumbing (for external capabilities) (non-breaking): new DOM event geoleaf:layer:added ({ layerId, sourceId, geometryTypes }), emitted once per layer as soon as its MapLibre sub-layers are created (GeoJSON layers and POI cluster groups). The MapLibre layer registry now exposes the real geometry type of a layer: geometryTypes field plus a getGeometryTypes(layerId) accessor — distinct from the sub-layers created (a polygon also creates a line sub-layer for its outline). These additions let an external capability apply per-layer styling without polling. First consumer: the MIT plugin @geoleaf-plugins/taxonomy — geometry-agnostic per-category styling (POI icon, polygon fill, polyline stroke) driven by a declarative value → style mapping (named reusable taxonomies, explicit categoryField). The core POI/GeoJSON rendering engine is unchanged (the legacy POI taxonomy stays in place; its migration is planned with the POI dissolution).

  • i18n seam GeoLeaf.I18n.t(key, fallback?) (non-breaking): the GeoLeaf.I18n namespace now exposes t(key, fallback?) in addition to registerDict / getLabel. It resolves key through the registered dictionaries (core + plugins) and falls back to fallback (or the key) when unresolved. This seam — promised by the field-renderer contract but never mounted — lets capabilities (feature-info) and plugins translate their labels (aria-labels…); output is byte-identical to the fallback when no dictionary provides the key.

  • Generic attribute filter — in-core capability GeoLeaf.Filter + modules.filter (rework): the filter panel becomes a generic built-in capability, geometry-agnostic (point/line/polygon) and multi-source, driven by a declarative field descriptor (modules.filter.fields[], 6 kinds: taxonomy / tag / range / text / boolean / proximity), with opt-in per-layer scope (layers: absent ⟹ all layers, present ⟹ only those). New read-only facade GeoLeaf.Filter (isEnabled(), getConfig()), introspectable through GeoLeaf.Introspection.getCapabilitySchema("filter"), active by default (opt-out through modules.filter.enabled: false). The predicate is hybrid: native MapLibre setFilter (GPU, no re-tiling) for taxonomy / boolean / range, JS fallback for tag / text / proximity (list, substring, haversine distance). taxonomy and tag share a single engine (membership of a set of values on a field).

  • Toast renderer — in-core capability toast-renderer + modules.toast-renderer (internal reclassification, additive): the DOM rendering of notifications ("toasts") becomes a built-in capability (capabilities/toast-renderer/), active by default (opt-out through modules.toast-renderer.enabled: false). The notify() primitive stays in the kernel (GeoLeaf.notify(message, level) — a stable anchor mounted at boot, buffer plus console.* fallback); the capability is its pluggable renderer (through registerRenderer()) and re-mounts the rich surfaces (GeoLeaf.UI.Notifications, _UINotifications, ESM export { Notifications }) — public API and behaviour unchanged by default. Without the capability (opt-out disabled), GeoLeaf.notify() degrades cleanly to console.*. Fix: the "default style not found" warning of a GeoJSON layer, silent until now (its internal reader GeoLeaf.Notifications was never mounted), is now emitted through the primitive.

Changed ​

  • Offline — engine moved in-core (modules.offline, dynamic import()) (breaking — profiles + distribution): the offline engine (IndexedDB + cache + download + sync, ~9,000 lines) moves from @geoleaf-plugins/storage to @geoleaf/core (capabilities/offline/), loaded through a dynamic import() (outside the boot budget — bootGz unchanged) on the opt-in gate modules.offline.enabled (which depends on modules.pwa.enabled). The configuration moves from modules.storage.* → modules.offline.* ({ enabled, cache: { enableProfileCache, enableTileCache } }). The residual plugin @geoleaf-plugins/storage is reduced to the offline selection UI (published on npmjs, access: public); it drives the core engine through the StorageContract seam (new whenReady(): Promise<void> — the UI defers its actions while the engine is not initialised, and indefinitely if modules.offline is disabled). The public GeoLeaf.Storage facade is unchanged (init / isOffline / getStats / downloadProfileForOffline / …). Migration: rename config/plugins/storage.json → offline.json, Files.modules.storage → Files.modules.offline, modules.storage.cache.* → modules.offline.cache.*. With modules.offline.enabled absent or false → no engine chunk downloaded.

  • Permalink — activation and configuration moved from ui.permalink to modules.permalink (breaking — profiles): state ↔ URL synchronisation (deep linking) becomes a built-in capability (permalink, opt-out — active unless modules.permalink.enabled: false), introspectable through GeoLeaf.Introspection.getCapabilitySchema("permalink"). The ui.permalink block (config/core/ui.json) moves to modules.permalink ({ enabled, mode }), on the modules.share / modules.legend model. The GeoLeaf.Permalink facade is unchanged (extended with isEnabled / getConfig). Behaviour change: opt-in → opt-out (permalink active by default; previously only ui.permalink.enabled: true enabled it). Internally, restoring the filter state now goes through the public GeoLeaf.Filter contract (no more DOM scraping or injection of fake hidden fields). The taxonomy is serialised into a single flat gl_cats parameter (the legacy gl_subs is removed — aligned with the generic filter model where categories and sub-categories form a single set of values). Migration: replace "ui": { "permalink": { … } } with "modules": { "permalink": { … } } (or omit it — opt-out).

  • GeoLeaf.Filter — new serialisation surface (non-breaking, additive): the facade exposes getActiveFilter() (serialisable active filter state), applyFilter(state) (restoration without DOM), reset(), applyNow(), hasActiveFilters() and proximity.{setRadius,toggle} — a contract consumed by the permalink capability and available for no-code use.

  • Share button — enabled through modules.permalink.share.enabled (breaking — profiles): the "Share this view" button (link modal + QR code) becomes a sub-feature of the permalink capability (share only makes sense with permalink, which encodes the shared URL). Opt-out gate modules.permalink.share.enabled (active unless false), introspectable through GeoLeaf.Introspection.getCapabilitySchema("permalink") (sub-key share). The former ui.showShareButton flag (config/core/ui.json) is removed. The GeoLeaf.Share facade (openShareDialog / closeShareDialog / getShareUrl / isOpen / isEnabled / getConfig), the geoleaf:toolbar:action event, the lazy QR code (qrcode-generator) and the rendering are unchanged. Migration: replace "ui": { "showShareButton": false } with "modules": { "permalink": { "share": { "enabled": false } } } (omitting it leaves the button active — opt-out). Internally, the code lives under capabilities/permalink/share/.

  • Taxonomy — unification: the core readers (legend, filter, POI icons) switched to the GeoLeaf.Taxonomy capability; legacy taxonomy removed (breaking — profiles + internal API): the legend, the taxonomy filter and POI icon injection now read their categories through the in-core capability GeoLeaf.Taxonomy (modules.taxonomy block), and no longer through the core's old "flat" taxonomy (config/core/taxonomy.json read via GeoLeaf.Config.getCategories()). Activation by binding: a POI layer receives its icons/categories only if it is declared in modules.taxonomy.layers ({ "<layerId>": { "use": "poi-cat" } }); a taxonomy filter must carry taxonomyRef (e.g. "poi-cat"). Removed (internal _/non-contractual API): GeoLeaf.Config.getCategories() / getCategory() / getSubcategory() / loadTaxonomy(), GeoLeaf._ConfigTaxonomy, and the ConfigInitOptions.{mappingUrl, mappingHeaders, mappingStrictContentType} options. Unchanged: the public GeoLeaf.Taxonomy facade, taxonomy resolution for modular profiles. (Update: GeoLeaf.Config.getIconsConfig() and the config/core/taxonomy.json file are now removed as well — see §Removed "Legacy taxonomy".) Migration: populate modules.taxonomy.taxonomies (see config/plugins/taxonomy.json), bind POI layers through modules.taxonomy.layers, add taxonomyRef to taxonomy filters; replace any GeoLeaf.Config.getCategories() call with GeoLeaf.Taxonomy.getCategories("poi-cat"). Rendering is byte-identical (the model reuses the tree, labels and icons of the old one). Note: modules.taxonomy.icons.defaultIcon is NOT applied to POI icon injection (parity with the old engine, which injected no default icon). Associated rendering fix: the icons of POI layers loaded through a data theme change (without a basemap change) are now correctly registered in the MapLibre rendering engine when the layer is added — previously the sprite was only (re)registered on a basemap change, so a POI layer appearing through a theme could display without its icons.

  • Legend — activation and configuration moved from ui.showLegend + legendConfig to modules.legend (breaking — profiles): the map legend becomes a built-in capability (legend), gated through CapabilityRegistry (opt-out: active unless modules.legend.enabled: false), introspectable through GeoLeaf.Introspection.getCapabilitySchema("legend"). The ui.showLegend flag and the legendConfig block (config/core/ui.json) move to the dedicated modules.legend block (config/plugins/legend.json file: { enabled, title, position, collapsedByDefault }), on the modules.table / modules.theme-selector model. Behaviour change: title / position / collapsedByDefault, previously ignored (overwritten by internal defaults), are now effectively applied to the control — a profile carrying the old legendConfig will see its legend rendered with its configured title, position and collapsed state (previously frozen at "Legend", bottom-left, expanded). New DOM event geoleaf:legend:ready ({ position, layerCount }) emitted once at the first mount of the control. The GeoLeaf.Legend facade is unchanged. Migration: create config/plugins/legend.json (carry over the legendConfig keys plus enabled: true), declare "legend": "config/plugins/legend.json" in profile.json → Files.modules, remove ui.showLegend and legendConfig from ui.json.

  • Theme selector — activation moved from ui.showThemeSelector to modules.theme-selector.enabled (breaking — profiles): the flag controlling the theme selection bar leaves config/core/ui.json (ui.showThemeSelector) for the dedicated modules.theme-selector block (config/plugins/theme-selector.json file, { "enabled": true }), on the same model as modules.table / modules.filter. The selector becomes a declared built-in capability (theme-selector), gated through CapabilityRegistry (opt-out: active unless modules.theme-selector.enabled: false), introspectable through GeoLeaf.Introspection.getCapabilitySchema("theme-selector"). Migration: create config/plugins/theme-selector.json with { "enabled": true }, declare "theme-selector": "config/plugins/theme-selector.json" in profile.json → Files.modules, remove ui.showThemeSelector from ui.json. The GeoLeaf.ThemeSelector facade, the events, the gl_theme permalink field and the rendering are unchanged. Internally, layer loading is now decoupled from the theme system (through the registry: GeoJSONModule loads the data, ThemeEngineModule applies the default theme) — data displays even without a declared theme or with the selector disabled; output is byte-identical when themes exist.

  • Clustering configuration moved from poiConfig to modules.cluster (breaking — profiles): the global clustering keys leave poiConfig for the dedicated modules.cluster block (config/plugins/cluster.json file) — poiConfig.clustering → modules.cluster.clustering, poiConfig.clusterStrategy → …clusterStrategy, poiConfig.clusterRadius → …clusterRadius, poiConfig.disableClusteringAtZoom → …disableClusteringAtZoom, poiConfig.clusterStrategies → …clusterStrategies. The per-layer overrides (layers[].clustering.{enabled,maxClusterRadius,disableClusteringAtZoom}) are unchanged. The features key clusteringConfig (never read at runtime) is removed (schema + profiles). Migration: move any poiConfig.cluster* block to config/plugins/cluster.json, declare "cluster": "config/plugins/cluster.json" in profile.json → Files.modules, remove clusteringConfig. Values are carried over identically (rendering unchanged); clustering is active by default without configuration. Removed along the way (internal _-prefixed, non-contractual): GeoLeaf._GeoJSONClustering, GeoLeaf.GeoJSON._getClusteringStrategy, GeoLeaf.GeoJSON._getPoiConfig.

  • @geoleaf-plugins/feature-info — public API GeoLeaf.FeatureInfo completed to 5 methods (breaking, plugin only — @geoleaf/core unaffected): openSidePanel(detail, layout?) and getConfig(layerId) added (isEnabled/close/openPopup already present). close() now closes the popup and the side panel. openPopup(detail) now requires detail.geometry (previously forced to null internally) — a scripted caller must supply that field (null accepted). The side panel is rewritten as standalone DOM (appended to document.body), without any dependency on GeoLeaf.POI. Action buttons (type: "action") dispatch geoleaf:popup:action (an already documented event) with properties limited to the fields configured for the surface — never the raw set of feature properties.

  • GeoLeaf.FeatureInfo.openPopup now accepts a second layout?: SidePanelLayout parameter (non-breaking — optional parameter added), mirroring openSidePanel. It lets a caller without a layers.<id>.capabilities.feature-info configuration (for instance POI injection) supply an explicit layout rather than falling back on generic auto-resolution (all properties as plain text).

  • POI — popup and side panel fully delegated to @geoleaf-plugins/feature-info (breaking): POI attribute rendering (popup on marker click, "See more" side panel) now requires the @geoleaf-plugins/feature-info plugin to be loaded and enabled (modules.feature-info.enabled: true). There is no internal core fallback rendering any more — without the plugin, clicking a POI marker produces neither popup nor panel (the same convention as any absent optional plugin). Migration: make sure @geoleaf-plugins/feature-info is loaded (already required for GeoJSON/VT attribute rendering) and that modules.feature-info.enabled is true in the active profile.

  • Routes — reworked into the in-core capability modules.route (endpoint decorator); the GeoLeaf.Route facade and the routes[] model removed (breaking — profiles + API): the legacy Route module (imperative GeoLeaf.Route facade, top-level routes[] array) is dissolved into an in-core capability (route, gate modules.route, opt-in), introspectable through GeoLeaf.Introspection.getCapabilitySchema("route"). New model: the capability decorates an existing polyline layer with start / end markers derived automatically from each feature's geometry — the track stays a generic GeoJSON line layer (rendered by the engine). Per-layer binding modules.route.layers.<id>.{start,end,showStart,showEnd} (the modules.taxonomy.layers model). Event-driven (geoleaf:layer:added / geoleaf:map:ready). Removed: the GeoLeaf.Route facade (init / loadFromConfig / loadGPX / loadGeoJSON / show / hide / filterVisibility), the routes[] array, the _RouteLayerManager / _RouteLoaders / _RouteStyleResolver globals, the RouteContract contract, the geoleaf:route:loaded event, the lazy route chunk. Reading GPX / KML / KMZ: out of scope — use @geoleaf-plugins/file-import. Migration: (1) declare "route": "config/plugins/route.json" in profile.json → Files.modules plus { "enabled": true, "layers": { "<layerId>": { … } } }; (2) a dynamic route goes through GeoLeaf.Layers.setData(layerId, featureCollection) (the capability re-derives the markers) instead of GeoLeaf.Route.loadFromConfig. Track interactivity (click / hover) is still provided by the generic geoleaf:feature:click / hover seam of GeoJSON layers (unchanged).

  • GeoJSON filtering applied on the GPU (non-breaking, performance improvement): GeoLeaf.GeoJSON.filterFeatures(predicate) keeps its signature (the predicate remains a JS function — substring search, distance, nested fields all stay supported), but now applies the visible set through map.setFilter on the features' id (a match expression, without re-tiling the source) for non-clustered layers whose features all carry a unique properties.id. Clustered layers, or layers whose features have no id, fall back to re-sending the data (the original behaviour, cluster counters preserved). Filtering by category or search in the panel is smoother on large datasets; no API or filtering-result change.

  • Basemap change: native layer preservation (transformStyle) (non-breaking in observable behaviour): switching a vector basemap now uses map.setStyle(next, { diff: true, transformStyle }) (MapLibre v5) to natively preserve the GeoLeaf sources and layers (GeoJSON, POI clusters, sentinel) instead of destroying everything and re-injecting it in JS. The reset-and-full-rebuild dance is over: a gain in correctness / leak avoidance (less churn, less transient layer loss) when switching. POI sprite icons (cleared by setStyle) are re-registered after the swap. Rendering and switching behaviour are unchanged.

  • POI — hover / selection halo (new UX, non-breaking): hovering a (non-clustered) POI marker draws a highlight border; clicking a marker selects it (persistent halo until another marker is clicked). Driven by setFeatureState (reactive GPU paint), with no extra data and no configuration. The resting rendering (colours, status badge) is unchanged.

  • POI — synchronisation badge without source rebuild (non-breaking, performance improvement): the "pending" badge (GeoLeaf.POI.updatePoiSyncStatus, used by @geoleaf-plugins/addpoi and @geoleaf-plugins/storage) is now applied through setFeatureState (an O(1) mutation) instead of rebuilding the whole POI FeatureCollection on every status change. The POI source declares promoteId: "id" (a stable feature id). No API change and no visible rendering change. Per-POI style resolution no longer forces a style recomputation (getComputedStyle) per marker on mass display.

  • Legend — "attribute → category" mapping driven by configuration (non-breaking for the core; potentially breaking for a profile relying on the former built-in tourism mapping): legend icon resolution for a when.field rule on a raw attribute (for example OpenStreetMap fclass) now reads a declarative taxonomy.fieldMappings mapping ({ <field>: { <value>: { categoryId, subCategoryId } } }) instead of a tourism table hard-coded in the core. The MIT core no longer carries any business data. Migration: a profile whose legend depended on the built-in fclass mapping (archaeological / museum / camp_site / hotel) must now declare it under taxonomy.fieldMappings (the tourism demonstration profile includes it).

  • Labels (GeoLeaf.Labels) — in-core capability + modules.labels gate (breaking — configuration): layer labels (text, rendered as a native MapLibre symbol layer) are now a declared built-in capability (labels), introspectable through GeoLeaf.Introspection.getCapabilitySchema("labels"), active by default (opt-out through modules.labels.enabled: false). The global gate leaves the root key labels.enabled for modules.labels.enabled (the root labels key is now reserved for the i18n override dictionary). Per-layer label styling (font, colour, halo, scale) stays unchanged in the style files (label.* key). Migration: a profile setting labels.enabled: false at the root must move it to modules.labels.enabled: false. The GeoLeaf.Labels API, the rendering and the display button are unchanged.

  • Filter configuration migrated from searchConfig / ui.showFilterPanel to modules.filter (breaking — profiles): the filter/search panel leaves ui.json > searchConfig (plus the ui.showFilterPanel flag) for the dedicated modules.filter block (config/plugins/filter.json file, declared in profile.json → Files.modules.filter). Correspondences: ui.showFilterPanel → modules.filter.enabled (opt-out); searchConfig.title / searchPlaceholder / actions → modules.filter.*; searchConfig.radius{Min,Max,Step,Default} → a kind:"proximity" field; filters[] type:"search"|"proximity"|"tree"|"multiselect-tags" → fields[] kind:"text"|"proximity"|"taxonomy"|"tag". The "categories" filter becomes explicit (field / taxonomyRef / layers) and is only migrated if the features carry the category identifiers. The internal GeoLeaf.FilterPanel facade is removed (replaced by GeoLeaf.Filter); the internal _UIFilterPanel* globals go from an eager mount to lazy shims mounted on geoleaf:app:ready (permalink / mobile / ui-api consumers unchanged). Migration: move the searchConfig block to config/plugins/filter.json in the fields[] model, declare "filter": "config/plugins/filter.json" in Files.modules, remove searchConfig and ui.showFilterPanel.

  • Filter — text search insensitive to accents and word order (non-breaking, improvement): the text field of the Filter panel (in-core filter capability, kind:"text") now normalises accents and case (NFD plus diacritic removal) and splits the query into words — a field matches if all the terms appear in it, in any order. recif finds "Récif", gilles récif finds "Le Récif — Saint-Gilles". A strict superset of the previous behaviour (every existing match stays valid), with no dependency and no new parameter. It covers common search needs without an external full-text engine (see §Removed — GeoLeaf.Search).

Fixed ​

  • OGC API Features refresh (autoRefresh): a layer configured with autoRefresh now updates when the map moves (moveend). The source update call targeted a non-existent adapter method (a silent no-op) and the fetch result was discarded; new features are now applied.
  • POI clickable after a server id is assigned: after GeoLeaf.POI.updatePoiId(oldId, newId) (the @geoleaf-plugins/addpoi flow — temporary id → permanent id), clicking the marker finds the POI again. The click resolution index (invalidated on list length alone) is now invalidated on rename.
  • Legend — fallback colour aligned with the map: a style rule without an explicit colour now shows the same grey (#cccccc) in the legend and on the map (previously Leaflet blue #3388ff in the legend, grey on the map).
  • Legend (LayerManager) — robust layer registration: registering a layer in the LayerManager no longer depends on a geometry type detection inherited from Leaflet (the source of a latent crash); the type is derived from the cached geometryType.
  • Timer leaks: the periodic polling of the mobile toolbar (a 2 s setInterval) is now cleaned up by the lifecycle (no more leak on recreation); the offline detector no longer starts a polling timer when no pingUrl is configured (the browser's online/offline events are enough).
  • Basemap change: no more double firing of interaction events: after a basemap change (map.setStyle), re-injected layers no longer duplicate their delegated listeners. A click or hover on a feature (POI, GeoJSON, cluster, track) no longer emits geoleaf:feature:click / geoleaf:feature:hover / geoleaf:poi:click twice — the delegated listeners are now detached before the style is rebuilt (and when the map is destroyed). No API impact.
  • GeoLeaf.Notifications.* now mounted on the global: the full notification namespace (GeoLeaf.Notifications.{notify,success,error,warning,info,dismiss,clearAll,getStatus}) — documented (NOTIFICATIONS_API.md) and typed (index.d.ts → GeoLeafAPIRoot.Notifications: NotificationsAPI, non-optional) — was in fact never assigned on globalThis.GeoLeaf (only GeoLeaf.UI.Notifications was). A GeoLeaf.Notifications.success(...) over CDN, as documented, crashed (Cannot read properties of undefined). The facade is now mounted at boot, aligning runtime, documentation and types. GeoLeaf.notify() (the shorthand) and the ESM export import { Notifications } are unchanged. This also fixes a show({message,type}) example (wrong signature) in the @geoleaf-plugins/websocket README → show(message, type).

Removed ​

  • GeoLeaf._UIDomUtils.attachAccordionBehavior() and GeoLeaf.UI._attachAccordionBehavior() removed (breaking, internal _ API with no consumer): both attached accordion behaviour (toggling gl-is-open on a click on a .gl-accordion__header) to a container. Neither had any caller in the product since the removal of the filter panel builder ui/filter-panel/**, their only client. Only the first was documented, with an example, in GeoLeaf_UI_Components_README.md — that section is gone. Despite its name, GeoLeaf.UI._attachAccordionBehavior() was not an alias of the first: it delegated to _UIEventDelegation.attachAccordionEvents(), which stays in place. Migration: none for the product's accordions — the legend builds its own (GeoLeaf.Legend) and the side panel uses a native <details>, without JavaScript. An integrator who called GeoLeaf._UIDomUtils.attachAccordionBehavior(container) on their own markup now attaches the listener themselves: container.addEventListener("click", (e) => e.target.closest(".gl-accordion__header")?.closest(".gl-accordion")?.classList.toggle("gl-is-open")).

  • _UIFilterPanel* globals removed (_UIFilterPanelApplier / _UIFilterPanelStateReader / _UIFilterPanelAccordion / _UIFilterPanelProximity) (breaking, transitional internal _ API): these runtime shims (installed by capabilities/filter/compat.ts) exposed the filter panel to permalink, to the mobile toolbar and to the desktop control builder. All their consumers are now wired to the public GeoLeaf.Filter contract; compat.ts and the four _UIFilterPanel* globals are deleted. Migration: GeoLeaf._UIFilterPanelProximity.* → GeoLeaf.Filter.proximity.*; _UIFilterPanelApplier.applyFiltersNow(...) → GeoLeaf.Filter.applyNow() / .reset(). (The singular legacy global _UIFilterPanel — the older panel — is not concerned.)

  • GeoLeaf.Taxonomy.resolveLabel() / resolveLayerLabel() removed (breaking, internal API with no consumer): these two taxonomy facade methods (readable label of a category value / of a layer badge field) had no consumer — they were intended for a feature-info badge rendering that was never wired. Removed from GeoLeaf.Taxonomy (they were needlessly shipped in the bundle). Migration: read the category directly through GeoLeaf.Taxonomy.getCategories(ref)[value]?.label.

  • GeoLeaf.Helpers.resolvePoiColors() — the colorRoute field removed from the PoiColors return (minor breaking, dead field): the function returned { colorFill, colorStroke, colorRoute } where colorRoute was computed identically to colorStroke and never read (a leftover of the dissolved route module). The return is now { colorFill, colorStroke }. Migration: use colorStroke (a strictly identical value).

  • Legacy taxonomy removed — GeoLeaf.Config.getIconsConfig() plus the config/core/taxonomy.json file (breaking — profiles + internal API): the core's old "flat" taxonomy is entirely deleted; the modules.taxonomy capability (config/plugins/taxonomy.json file) is now the single source of icons and categories. Removed: GeoLeaf.Config.getIconsConfig() (plus ProfileManager.getIconsConfig), the config/core/taxonomy.json file and its Files.taxonomyFile manifest entry (loaded at boot through profile-loader), the internal GeoLeafConfig.categories field (plus its _validateCategoriesSection validation and the CategoryItem interface), and the dead internal API GeoLeaf.UI._populateSelectOptionsFromTaxonomy (0 callers). The POI icon sprite and the legend's showOnMap gate now read modules.taxonomy.icons through GeoLeaf.Taxonomy.getIcons(). Migration: delete the config/core/taxonomy.json file and the Files.taxonomyFile key from profile.json — icons and categories already live in config/plugins/taxonomy.json (modules.taxonomy.icons + modules.taxonomy.taxonomies); replace any GeoLeaf.Config.getIconsConfig() call with GeoLeaf.Taxonomy.getIcons(). Rendering is byte-identical (same data, same sprite URL).

  • The geoleaf:style:rebuild DOM event removed (breaking): this event, emitted after a map.setStyle() so that modules could re-inject their layers, no longer has a purpose — the basemap switch now natively preserves GeoLeaf layers through transformStyle (see §Changed). The event is removed from the contract (GeoLeafEventMap) and its 3 internal listeners (GeoJSON, POI, taxonomy) are deleted, along with the associated rebuild functions (_rebuildGeoJSONLayers, _rebuildPoiClusterSource, the taxonomy sweep on rebuild). No distributed plugin listened to it. Migration: an integrator who listened to geoleaf:style:rebuild in order to restyle after a basemap change no longer needs it (layers and their paint survive); to react to a layer being added, use geoleaf:layer:added. The optional adapter method resetForStyleChange() is replaced by buildStyleChangeTransform() + reregisterStyleImages().

  • GeoLeaf._loadModule("poiRenderers") / "poiRenderers" removed from LazyModuleName (breaking, lightly documented internal API): the lazy poi-renderers chunk (legacy POI attribute rendering — field/media/component renderers, section orchestrator, lightbox, UI behaviours) is deleted, not moved — that capability is now entirely provided by @geoleaf-plugins/feature-info. GeoLeaf._loadModule("poi") keeps working (it loads poiCore + poiExtras, without poiRenderers). A direct call to GeoLeaf._loadModule("poiRenderers") now fails silently (the default branch of the switch, console.warn).

  • GeoJSON popup/tooltip binders removed from the core (breaking): bindMapLibrePopup, bindMapLibreTooltip, _GeoJSONPopupTooltip, setupPopupTooltipDeps and the 4 source files (popup-tooltip.ts, popup-tooltip-core.ts, popup-tooltip-layer.ts, popup-tooltip-maplibre.ts) are no longer part of @geoleaf/core. Tooltip/popup/side-panel rendering on GeoJSON and vector-tile layers is now provided by the MIT plugin @geoleaf-plugins/feature-info. Migration: install @geoleaf-plugins/feature-info and load its script after geoleaf.esm.js. GeoJSON layers now emit geoleaf:feature:click / geoleaf:feature:hover instead. POI impact: POI popup behaviour is unchanged (a separate geoleaf:poi:click channel).

  • GeoLeaf.Geocoding removed from the core (breaking): address search (geocoding — Addok/BAN, Nominatim, Photon providers, custom HTTPS endpoint) is no longer built into @geoleaf/core. It is now provided by the MIT plugin @geoleaf-plugins/geocoding (public on npmjs.org). Removed from the core bundle: the named ESM export Geocoding, the GeoLeaf.Geocoding facade (isEnabled / search / selectResult / open / destroy), the geoleaf:geocoding:result event and the .gl-geocoding-ctrl search control. Migration: npm install @geoleaf-plugins/geocoding, then load its script after geoleaf.esm.js (and before GeoLeaf.boot()).

  • GeoLeaf.Table removed from the core (breaking): the data table (tabular panel — layer selector, sorting, selection ↔ map highlight, zoom, GeoJSON/CSV/KML/GPX/Excel export) is no longer built into @geoleaf/core. It is now provided by the MIT plugin @geoleaf-plugins/table (public on npmjs.org). Removed from the core bundle: the named ESM export Table, the GeoLeaf.Table facade (show/hide/toggle/setLayer/sortByField/setSelection/zoomToSelection/exportSelection/exportLayer…), the geoleaf:table:* events, the "Table" tab of the desktop panel and the OOXML writer for Excel export. Migration: npm install @geoleaf-plugins/table, then load its script after geoleaf.esm.js (and before GeoLeaf.boot()).

  • GeoLeaf.Popup removed from the core (breaking): the GeoLeaf.Popup.registerActionHandler() / unregisterActionHandler() facade (a registry of rich-context handlers for popup action buttons) is deleted, along with the modules/built-in/popup/action-registry.ts module and its modules/built-in/popup/popup-actions.ts listener. This registry had not been invoked since popup rendering moved to @geoleaf-plugins/feature-info, which only dispatches the geoleaf:popup:action event — it had become a silent no-op. Migration: replace any call to GeoLeaf.Popup.registerActionHandler(actionId, handler) with GeoLeaf.events.on("geoleaf:popup:action", (e) => { if (e.detail.actionId === actionId) handler(e.detail); }). The rich context (button DOM reference, setBusy(), close()) is no longer available — only the JSON payload (actionId, layerId, featureId, properties, lngLat?) is.

  • GeoLeaf.GeoJSON.updateLayerZIndex() removed (breaking, lightly documented internal API): this method had no caller in the product and failed systematically on MapLibre (a historical Leaflet implementation — state.map.getPane() does not exist on the MapLibre adapter). Layer display order is handled natively by the adapter registry.

  • POI rendering routed through the generic seam + GeoLeaf.POI.openSidePanelWithLayout() removed (breaking): clicking a POI marker now emits geoleaf:feature:click (the same seam as GeoJSON/VT layers) instead of a POI-specific rendering path; @geoleaf-plugins/feature-info auto-resolves the layout from layers.<id>.capabilities.feature-info. The internal translation bridge (poi/feature-info-bridge.ts) and the POI popup delegate (poi/popup.ts) are deleted. The GeoLeaf.POI.openSidePanelWithLayout(poi, customLayout) API and the customLayout parameter of GeoLeaf.POI.showPoiDetails() are removed (no callers — the layout now comes from the capabilities.feature-info configuration). Migration: configure POI rendering under layers.<id>.capabilities.feature-info, like GeoJSON layers.

  • GeoLeaf.POI removed from the core — dissolution of the POI subsystem (breaking): the public GeoLeaf.POI namespace and the named ESM export POI are deleted. A POI is now a generic GeoJSON point layer (GeoLeaf.Layers), styled by taxonomy, clustered by cluster, rendered on click by feature-info — all in-core capabilities. Removed from the bundle: the monolithic POI rendering pipeline (built-in/poi/**, the poi-source aggregate), the resolveCategoryDisplay icon resolution (replaced by GeoLeaf.Taxonomy.resolvePoiIcon wired onto the generic point icon resolver), and every data method (getAllPois / getPoiById / getDisplayedPoisCount / displayPois / reload / addPoi / add / setFilteredDisplay / updatePoiSyncStatus / updatePoiId / getLayer / loadAndMergeStoredPois…). The filter and the search now read the single GeoLeaf.Layers source (search rebuilds its index on demand from the layers — POIs created at runtime become searchable again). Migration: read and mutate point data through GeoLeaf.Layers.getFeatures(layerId) / addFeature / mergeFeatures / setData; styling and click rendering are configured per layer (layers.<id>.capabilities.{taxonomy,cluster,feature-info}). For interactive creation of POIs (the addpoi plugin), see the GeoLeaf.POI.* → GeoLeaf.AddPOI.* migration below.

  • GeoLeaf.Themes removed from the core (dead per-layer theme facade) (breaking): the GeoLeaf.Themes facade (applyTheme / loadTheme / toggleTheme / getAvailableThemes / initializeLayerTheme / getCurrentTheme / clearRememberedThemes / invalidateCache / init) and the named ESM export Themes are deleted. A relic of the Leaflet era: a "per-layer theme" system loaded from data/profiles/<layer>/themes/index.json that produced no effect on the MapLibre map (the manager stored the theme id in an internal Map, without calling the adapter or the style). No replacement — the real theme engine (composition/application, geoleaf:theme:applied event) stays internal and unchanged (ThemeApplierCore), driven by the profile configuration and the UI theme selector (GeoLeaf.UI, light/dark). Migration: remove any GeoLeaf.Themes.* call — the visual theme is driven by the profile configuration plus the UI selector.

  • GeoLeaf.Search removed from the core — the full-text search engine (flexsearch) purged (breaking, dormant engine): the GeoLeaf.Search facade (isReady / query / build / getEngine / clear), the named ESM export Search, the SearchRegistry (the flexsearch index engine) and the lazy search chunk are deleted, along with the flexsearch npm dependency (removed from @geoleaf/core). That engine was dormant: no core code and no profile enabled it (profile.search.engine === "flexsearch" was never set, loadModule("search") was never called, the index was never built). The interface's actual text search (the "Rechercher un POI…" field of the Filter panel) is provided by the in-core filter capability (substring search), unchanged and now insensitive to accents and word order (see §Changed). Configuration removed along the way: the layer search block ({ enabled, indexingFields }), the ui.showSearch flag, the root searchConfig.engine key. The removal takes flexsearch out of the boot eager closure (the named export anchored it there, contrary to the "lazy" belief): boot −8 KB gz. Migration: no action for the UI (the filter covers search); an integrator calling GeoLeaf.Search.query() from a script must implement their own search (or index server-side) — the core no longer exposes a full-text engine.

  • GeoLeaf.GeoJSON.addData / .loadUrl / .clear / .getLayer removed (breaking, dead methods): these 4 methods inherited from Leaflet were inert in MapLibre mode — the geoJsonLayer / layerGroup state they manipulated is never assigned (always null): addData logged an error then drew nothing, loadUrl performed a network fetch then discarded the result, clear / getLayer were no-ops. The GeoJSON module remains fully functional: profile-driven loading (GeoLeaf.GeoJSON.loadFromActiveProfile() plus the layers configuration), live updates through updateLayerData(layerId, data), reads through getLayerById / getLayerData / getLayerConfig / getAllLayers, filtering through filterFeatures / clearFeatureFilter. Migration: replace ad hoc addData / loadUrl with the profile's layer configuration or updateLayerData(); getLayer() → getLayerById(id).

  • GeoLeaf.Route.loadGPX / .loadGeoJSON removed (breaking, dead methods): these two ad hoc route loading methods were broken in MapLibre mode — they took an internal path (_applyRoute → RouteLayerManager.applyRoute({ layerGroup })) whose layerGroup is never assigned; loadGPX performed a fetch plus DOMParser parsing then drew nothing. Route loading is still provided by the living configuration path GeoLeaf.Route.loadFromConfig(routes) (through adapter.addGeoJSONLayer), distinct from these methods. Migration: declare routes in the profile configuration (loaded by loadFromConfig) rather than calling loadGPX / loadGeoJSON.

  • GeoLeaf.Utils — dead quartet removed (AnimationHelper, EventHelpers, FileValidator, LazyLoader) plus top-level shortcuts (breaking, public surface without a reader): GeoLeaf.Utils.AnimationHelper / .EventHelpers / .FileValidator / .LazyLoader, the top-level GeoLeaf.FileValidator, and the GeoLeaf.animate / .fadeIn / .fadeOut / .loadModule / .enableLazyImages / .dispatchEvent / .dispatchMapEvent shortcuts are deleted (0 readers outside tests). Not to be confused: the secondary module loader GeoLeaf._loadModule(name) (with an underscore) is unchanged and alive. The rest of GeoLeaf.Utils (FetchHelper, MapHelpers, DOMSecurity, PerformanceProfiler, TimerManager, debounce, throttle, getDistance, ObjectUtils, ScaleUtils, …) is unchanged. Client-side file validation remains available in the plugins that use it (@geoleaf-plugins/addpoi and @geoleaf/field-renderer ship their own validator). Migration: replace GeoLeaf.animate / fadeIn / fadeOut with CSS transitions; GeoLeaf.dispatchEvent with GeoLeaf.events or document.dispatchEvent; there is no core replacement for GeoLeaf.Utils.FileValidator.

  • GeoLeaf.Filters.filterPoiList and the 6 statistical functions removed (getUniqueCategories, getUniqueSubCategories, getUniqueTags, countByCategory, countBySubCategory, getRatingStats) (breaking): 0 internal consumers. GeoLeaf.Filters.filterRouteList is unchanged. Migration: for POI filtering, use the GeoLeaf.Filter capability (singular) — getActiveFilter() / applyFilter(state) / hasActiveFilters() — which drives the active in-core filter panel.

  • GeoLeaf.Config.getActiveProfilePoi() removed (breaking, dead method): it always returned [] (POIs having been dissolved out of the profile). ProfileManager._activeProfileData.{poi,routes} removed internally (the ProfileDataPayload type that still types profiles[] is kept).

  • The permalink poi parameter removed (gl_poi in verbose mode) (minor breaking): this field made a URL→state→URL round trip without ever influencing application behaviour (a relic of the dissolved POI era). The other fields (lat/lng/zoom/layers/filter/categories/tags/rating/theme) are unchanged.

Breaking Changes (layer scale thresholds: zoomConfig → scaleConfig) ​

  • The zoomConfig block of layers/{layer}/styles/{style}.json is removed, replaced by scaleConfig: zoomConfig.minZoom/maxZoom become scaleConfig.minScale/maxScale. This is not just a rename: it is the correction of a trap. The old name announced MapLibre zoom levels (0-24) while the engine has always read scale denominators (the X in 1:X) — writing minZoom: 6 therefore hid the layer at every zoom, silently. The new name states the unit that has always been the engine's.
    • No shim: the zoomConfig block is rejected at validation (additionalProperties: false schema plus runtime validator), with a message naming the replacement. A profile that has not migrated fails loudly instead of silently losing its constraint. The minZoom/maxZoom alias inside scaleConfig is rejected too — that alias is what let a zoom level through.
    • New guard: any bound in (0; 24] is refused — such a denominator is unreachable at any zoom, so it is always a zoom level entered by mistake. 0 and null remain valid ("constraint disabled").
    • Migration — your values were already denominators (the normal case): rename the keys, values unchanged. { "zoomConfig": { "minZoom": 500000, "maxZoom": 10000 } } → { "scaleConfig": { "minScale": 500000, "maxScale": 10000 } }.
    • Migration — your values were zoom levels (so your layers were invisible): convert with 1:X = 591,658,734 × cos(latitude) / 2^zoom, at the latitude of your area. For example at ~4°N: zoom 6 → 9222148, zoom 18 → 2252. Reference points: zoom 5 ≈ 1:18,444,296 · zoom 10 ≈ 1:576,384 · zoom 13 ≈ 1:72,048 · zoom 20 ≈ 1:563.
    • Reminder about meaning (counter-intuitive): minScale is the larger of the two numbers — it bounds the widest view, and a denominator grows as you zoom out. { "minScale": 9222148, "maxScale": 2252 } = "visible from 1:9,222,148 to 1:2,252".
    • labelScale.minScale/maxScale (label scale) is unchanged: same unit, same guard, but it targets labels and not the layer.

Breaking Changes (geocoding → plugin) ​

  • The geocodingConfig configuration key (profile root) is removed: geocoding configuration moves to the modules.geocoding block, declared in config/plugins/geocoding.json and referenced by Files.modules.geocoding. GeoLeaf.Config.get("geocodingConfig") now returns undefined. No compatibility shim — a profile keeping geocodingConfig at the root no longer loads the geocoding configuration. Migration: move the geocodingConfig block to config/plugins/geocoding.json (keys unchanged: enabled, provider, debounceMs, minChars, resultLimit, position, placeholder, flyToZoom, bbox, countrycodes) and declare it in Files.modules.geocoding. Details and examples: README of @geoleaf-plugins/geocoding.

Breaking Changes (table → plugin) ​

  • The tableConfig (profile root) and ui.showTable configuration keys are removed: the global table configuration moves to the modules.table block, declared in config/plugins/table.json and referenced by Files.modules.table. GeoLeaf.Config.get("tableConfig") now returns undefined; ui.showTable is replaced by modules.table.showButton. No compatibility shim — a profile keeping tableConfig/ui.showTable at the root no longer loads the table configuration. Migration: move the block to config/plugins/table.json (keys: enabled, showButton, defaultVisible, pageSize, maxRowsPerLayer, enableExportButton, virtualScrolling, defaultHeight, minHeight, maxHeight, resizable) and declare it in Files.modules.table. The per-layer binding layer.config.table.* (columns, sorting, title) stays unchanged on the layer (layer-config.schema.json untouched). Details and examples: README of @geoleaf-plugins/table.

Breaking Changes (POI dissolution → generic layers) ​

  • The poiConfig configuration key (features.json) is removed: the POI subsystem having been dissolved, the global poiConfig block ({ enabled }) no longer has an object — it is removed from the 9 profiles, from the features.schema.json schema and from the profile schema. GeoLeaf.Config no longer reads poiConfig nor the poi[] array (inline POIs). No shim — since features.schema.json is additionalProperties:false, a profile keeping poiConfig fails validation. Migration: remove the poiConfig block from features.json. Point layers are declared in layers[] like any GeoJSON layer; clustering is already under modules.cluster.
  • @geoleaf-plugins/addpoi plugin API: GeoLeaf.POI.* → GeoLeaf.AddPOI.* (breaking, plugin): GeoLeaf.POI having been removed from the core, the addpoi plugin's public POI creation namespace moves to GeoLeaf.AddPOI. GeoLeaf.POI.AddForm.* → GeoLeaf.AddPOI.AddForm.*; GeoLeaf.POI.PlacementMode.* → GeoLeaf.AddPOI.PlacementMode.* (likewise for ImageUpload). Migration: replace the GeoLeaf.POI. prefix with GeoLeaf.AddPOI. in your integration code. Created POIs are now written to the editable host layer (gl-src-<layerId>) through GeoLeaf.Layers, alongside static features (fixing the split-brain).

Breaking Changes (per-layer attribute rendering → capabilities.feature-info) ​

  • The popup, tooltip, sidepanelConfig (blocks) and tooltipMode (root alias) keys of {id}_config.json are removed: the tooltip/popup/side-panel rendering configuration of a layer moves to the capabilities.feature-info block (an opaque property of the @geoleaf-plugins/feature-info plugin; keys titleField, tooltip, popup, sidepanel). The layer schema (additionalProperties: false at the root) now rejects those keys. No shim — a profile keeping popup/tooltip/sidepanelConfig/tooltipMode at the root of {id}_config.json fails AJV validation. Migration: move the fields under capabilities.feature-info — tooltip.fields → capabilities.feature-info.tooltip, popup.fields → .popup, sidepanelConfig.detailLayout → .sidepanel, plus a titleField (the dotted path of the title). Details and examples: README of @geoleaf-plugins/feature-info.
  • POI taxonomy badges (temporary regression): without the removed translation bridge, a badge field bound to the taxonomy (categoryId/subCategoryId) displays the raw identifier instead of the label. Label/icon/sprite resolution by the taxonomy will be handled by a dedicated taxonomy rendering configuration (upcoming) — outside the capabilities.feature-info block.

Security ​

  • Strict style-src CSP — 'unsafe-inline' removed: every inline style in the rendering (popup badges and tables, POI markers, legend, the style attribute of the SVG sprite, demo controls) is now applied through the CSSOM (element.style.setProperty) or through CSS classes, never through an inline style attribute or <style> element. The style-src directive of the deployment template no longer contains 'unsafe-inline'. A guard end-to-end test checks for 0 style-src violations at boot and when rendering a hostile POI. See the security integration guide for the recommended CSP.
  • XSS hardening in POI rendering: the href/src sinks of the link/image POI sections now validate the URL through GeoLeaf.Security.validateUrl() — a URL with a disallowed protocol (javascript:, vbscript:, data:text/html…) is no longer rendered (the element is omitted). No change for legitimate http(s)/data:image URLs (the attribute is normalised to an absolute URL).
  • GeoLeaf.Security.sanitizeSvgContent(): now strips SMIL animation elements (<animate>, <set>, <animateTransform>, <animateMotion>, <animateColor>, <mpath>) from untrusted SVG — they can mutate attributes at runtime (e.g. <set attributeName="href" to="javascript:…">).
  • Anti prototype pollution: Config.merge()/set() filter out the __proto__/constructor/prototype keys.
  • Profile sprite: the taxonomy.icons.spriteUrl URL is validated (validateUrl) before the fetch.
  • CDN integrity (demo): MapLibre GL JS is loaded from unpkg with an integrity attribute (SRI sha384) in the deployment template.
  • Anti-clickjacking: a recommendation for server headers (X-Frame-Options: DENY + frame-ancestors 'self') has been added. See the new integrator security guide. (The style-src of the reference CSP no longer requires 'unsafe-inline' — see the "Strict style-src CSP" entry above.)
  • Removal of the xlsx (SheetJS) dependency: Excel export now uses a minimal internal OOXML writer (write-only, no third-party dependency), eliminating 2 CVEs (prototype pollution CVE-2023-30533 plus a ReDoS) that were bundled in the export chunk. No API change (Excel remains a table export format).
  • Dependencies: patched versions pinned (dompurify, markdown-it, protocol-buffers-schema); the remaining vulnerabilities concern development tooling only (not shipped at runtime).

Removed ​

  • GeoLeaf.Security.sanitizePoiProperties() deleted (breaking): this helper was wired on no production path — POI text is escaped at the rendering sinks (popup setSafeHTML, side panel normalizePoi) and URLs are validated through validateUrl(). It maintained a false coverage signal (tested but never called). To sanitise external data before rendering it outside GeoLeaf, use escapeHtml() (text) plus validateUrl() (URLs), or sanitizeHTML(el, html) to inject HTML.
  • Legacy format fallbacks deleted (breaking — v3.0.0 clean slate): the runtime now accepts only the canonical form of the following keys:
    • sizePx (point size) → use radius: the alias is no longer normalised to radius in flat styles. (Unrelated: label.buffer.sizePx — the thickness of a label halo — stays valid and unchanged.)
    • vectorTiles.url → use vectorTiles.tilesUrl: tilesUrl is the canonical key (already required by the profile schema); the input alias url is no longer recognised.
    • layerScale (scale visibility) → use scaleConfig: the legacy alias and its deprecation warning are removed. The canonical form is scaleConfig.minScale/maxScale — see the "Breaking Changes (layer scale thresholds)" section above, which gives the unit and the conversion. (labelScale remains supported — not affected.)
    • pointStyle (marker style override, layer level) deleted: a legacy block with no MapLibre rendering, unused by the profiles; use the flat style format (radius, fillColor…).
    • data.useLegacyProfileData deleted: legacy "flat" profile loading (separate poi.json/routes.json/mapping.json) no longer exists; only the modular profile format (config/core/* + Files) is loaded.
    • AddPOI plugin: deriving geometry from the legacy latlng: [lat, lng] field is removed — POIs must carry a GeoJSON geometry.
  • GeoLeaf.Utils.EventHelpers.debounce / .throttle deleted (minor breaking — v3.0.0 clean slate): these two methods were never-called duplicates in the EventHelpers namespace (dedicated to dispatching and listening to DOM events). Use the canonical functions GeoLeaf.Utils.debounce / GeoLeaf.Utils.throttle (unchanged). Note the slightly different defaults: debounce 250 ms (instead of 300), throttle 100 ms (instead of 300) — specify the delay at the call site if needed.

Breaking Changes (profile layout v2) ​

  • New profile tree: the section files now live in config/core/ (taxonomy.json, themes.json, layers.json, basemaps.json, ui.json plus a new features.json) and each plugin's configuration in config/plugins/<moduleId>.json. profile.json now contains only the identity (id, label, description, version), the map section and the Files manifest. Since paths are declared in Files, an existing profile stays readable if it updates its manifest; the top-level taxonomyFile/themesFile fallbacks (outside Files), however, are deleted. Migration: move the 5 section files to config/core/, extract clusteringConfig/geocodingConfig/performance/poiConfig from profile.json into config/core/features.json (referenced by Files.featuresFile), extract the plugin blocks (storage, poiAddConfig, editorConfig…) into config/plugins/<moduleId>.json (referenced by Files.modules).

Added ​

  • GeoLeaf.Helpers.applyCssText(el, css) (plus the named ESM export applyCssText): applies a CSS declaration to an element property by property through the CSSOM (style.setProperty), in a CSP-safe way (not subject to style-src, unlike el.style.cssText = …). A helper meant for code-owned dynamic styles. Also applyDeferredStyles(root) (applies the data-gl-style attributes of a subtree after insertion). Used by the core and the plugins to work under a strict style-src CSP (without 'unsafe-inline').
  • Files.featuresFile: a new section file for cross-cutting core features (clusteringConfig, geocodingConfig, performance, poiConfig, mapOptions), merged at the root of the consolidated profile like uiFile/basemapsFile.
  • Files.modules: a { moduleId: filePath } dictionary — one configuration file per plugin, merged into modules.<id> (Plugin Contract v1, opaque content for the core). An inline modules.<id> block in profile.json takes precedence over the file (deepMerge; arrays are replaced, not merged).
  • profile-bundle.json extended: the bundle generated at build time now embeds the features and modules sections; booting a deployment still takes 3 requests (root config + profile.json + bundle).
  • Debug mode = cascade: when debug: true is active in geoleaf.config.json, the loader ignores bundleFile and loads the file cascade — this allows editing a deployed profile live without regenerating the bundle.

Fixed ​

  • Core.destroy(mapId) — real lifecycle teardown: when the last map is closed, the shared business state (POI, GeoJSON, LayerManager, active profile) is now cleaned up through an internal lifecycle seam. A later Core.init() (React remount, SPA navigation, profile change) starts from a clean state — no more duplicated markers or layers, phantom profile or adapter leak. The public signature is unchanged. Validated in the browser.

  • Legend: the taxonomy is read from the already-loaded active profile instead of being re-downloaded through a hard-coded path (profiles/{id}/taxonomy.json) — this removes a redundant fetch and a latent 404 with layout v2 (the fetch fallback is kept for legacy profiles).

  • Theme selector: the same fix — themes are read from the active profile instead of a re-fetch of the hard-coded path profiles/{id}/themes.json.

  • Popup action buttons: a new renderer type type: "action" in popup.fields[] (GeoJSON layers) and popup.detailPopup[] (POI markers). A configurable button in the popup can trigger any host-side action — open a back-office record, call an API, emit an event — without coupling the core to a backend. Fields: actionId (required, opaque), labelKey/label, variant (primary/secondary/danger), order, href (opened by the core through validateUrl if no handler is registered), confirm/confirmKey, requiresPlugin (button disabled if the plugin is absent), payloadFields (allow-list of payload properties). Scope for v1: popup only.

  • GeoLeaf.Popup: a new public facade exposing registerActionHandler(actionId | "*", fn) and unregisterActionHandler(actionId). The fn(ctx) handler receives a rich context ({ actionId, feature?, poi?, layerId, featureId, properties, lngLat?, buttonEl, popup, setBusy, close }) and may return a Promise — the button then goes into a "busy" state until it resolves. Click precedence: exact handler → wildcard handler "*" → opening the built-in href. CSRF protection is the handler's responsibility (GeoLeaf.Security.CSRFToken.addTokenToHeaders()).

  • geoleaf:popup:action: a new event emitted on document at every click on a popup action button (emitted in all cases, whether or not a handler is registered). Payload: { actionId, layerId, featureId, properties, lngLat? } — properties bounded by payloadFields (default: id/name/title/label), functions and DOM references removed.

  • Per-module configuration — modules.<id>: plugin configuration is declared in a modules.<id> block of the profile (e.g. modules.storage, modules.print). The content of each block belongs to the plugin — the core treats it as opaque. This is now the only supported form (the fallback to legacy root keys has been removed, see Removed).

  • GeoLeaf.Config.getModuleConfig(moduleId, key?, defaultValue?): an accessor reading modules.<moduleId>.<key>. Dot-notation equivalent: GeoLeaf.Config.get("modules.<id>.<key>").

  • style.paint (layer styles) is now merged: a style.paint block (native MapLibre properties — fill-color, circle-radius, line-dasharray…) declared in a style file is now merged into the layer paint, like expressionPaint (which still applies last). Previously style.paint was silently ignored (only expressionPaint was applied) — profiles that declared it now see their rendering applied.

  • mapping.json contract — single multi-source form: mapping.json (normalising raw data from an external source into the POI format) is now always an object of blocks named by source { "<sourceId>": { mapping, … } } (a single source means a single block; the top-level { mapping } form is gone). Each mapping is flat ({ normalisedField: "sourceField" }, dotted paths allowed: location.lat, attributes.kind). Schema: mapping.schema.json.

  • External source normalisation when loading a layer: declare Files.mappingFile in the profile manifest, then point a GeoJSON layer at a source block through data.mapping: "<sourceId>" (plus the optional data.itemsPath, e.g. "results", to extract the array from a nested response such as the GBIF API). At load time the raw data is normalised (mapping.json → POI format) then rendered as GeoJSON Point features. Numeric id values (e.g. the GBIF key) are coerced to strings. The guyane-biodiversite demo profile illustrates the case with the observations_gbif layer (GBIF API).

  • New contractualised UI parameters: ui.showSearch, ui.showShareButton (default true) and ui.interactiveShapes (default false) are now declared in the ui schema — previously read by the code but not configurable.

  • data.vectorTiles.scheme ("xyz" | "tms"): the vector tile grid scheme is now configurable (e.g. "tms" for the IGN Géoplateforme).

  • data.ogcApi source: an OGC API Features configuration block, now formally declared (url required, plus collectionId/bbox/maxFeatures/limit/autoRefresh/autoRefreshDebounce/headers).

Removed ​

  • Breaking — root-level plugin configuration keys removed: storage, poiAddConfig, printConfig, measureConfig and editorConfig at the profile root are no longer recognised. The bidirectional mirror and the deprecation fallback have been deleted: modules.storage, modules.addpoi, modules.print, modules.measure, modules.editor are the only valid form (Plugin Contract v1, INV-CONFIG, now frozen). The corresponding interfaces have been removed from the GeoLeafConfig type API. Migration: declare each plugin configuration under modules.<id> (already the case for every shipped profile since layout v2) and read it through GeoLeaf.Config.getModuleConfig(id, key, default) or Config.get("modules.<id>.<key>").
  • Breaking — the lineColor/lineOpacity/lineWidth style aliases removed: these legacy keys had no effect (the converter reads only color/opacity/weight). They are removed from style.schema.json (and are now rejected at profile validation). Migration: lineColor→color, lineOpacity→opacity, lineWidth→weight.
  • Breaking — the top-level form of mapping.json removed: a mapping.json carrying mapping at the root is no longer valid; wrap it in a named source block (see "mapping.json contract" and "External source normalisation" in Added).
  • Breaking — GeoLeaf.UI.ScaleControl removed: this scale control was a duplicate (driven by ui.scaleType, not auto-initialised) of the active scale bar. Use the standard control, driven by scaleConfig (scaleGraphic/scaleNumeric/…) and initialised automatically at boot. The ui.scaleType parameter is deleted (replaced by scaleConfig).

Breaking Changes (multi-instance) ​

  • Core is no longer a singleton: Core.init({ mapId }) creates one instance per mapId instead of recycling a single one. init() now requires options.mapId (returns null plus an error log otherwise). Core.getMap(mapId?) accepts an optional mapId — without an argument it returns the first active instance (backwards compatible). The legend and the theme stay global and apply to the first instance (a deliberate scope). GeoLeaf.removeMap(id) is deprecated and aliased to Core.destroy(id) (with a warning). Migration: single-map apps → no change; multi-map apps → const a = GeoLeaf.Core.init({ mapId: 'unique-id', center, zoom }); /* when the component unmounts */ GeoLeaf.Core.destroy('unique-id');.

Added (multi-instance) ​

  • Core.destroy(mapId): cleanly destroys a keyed instance — calls MaplibreAdapter.destroy() (map.remove(), purge of markers/controls/registry) then frees the registry slot. Returns true if an instance existed, false otherwise. To be called on teardown by the consumer (e.g. React unmount).
  • Core.hasMap(mapId) / Core.listMaps(): introspection of the instance registry (debugging, devtools, tests).
  • Multi-instance support: N MapLibre maps coexisting on the same page, each with an independent lifecycle (mount/unmount).

Changed ​

  • Geocoding UI: the default geocodingConfig.position is now "top-left" (previously "top-right"). The geocoding pill adopts the visual style of the shared POI search bar (.gl-pill-search) with an SVG magnifier icon on the submit button. Explicit position values in profiles are still honoured unchanged. Integrator migration: no action required; to keep the former behaviour, declare geocodingConfig.position: "top-right" in the JSON profile.
  • POI search: the searchConfig.filters[].type: "search" filter is now rendered in a dedicated section at the top of the filter panel (it was previously hidden). The input is placed in [data-gl-filter-id="searchText"] — an unchanged selector for state-reader. Live filtering triggers on every keystroke.
  • Mobile (≤ 768 px): the "search" button of the pill toolbar now opens the floating geocoding pill (address search) instead of the former POI bar. POI search on mobile is reachable through the FILTER tab/sheet, which holds the searchText pill in its header.

Removed ​

  • .gl-search-bar* (CSS) plus the mobile-toolbar-searchbar.ts module (the POI floating search bar) and the CSS variables --gl-search-bar-height / --gl-search-bar-gap: replaced by the .gl-pill-search component shared between the geocoding pill and the POI search pill in the filter panel. Integrators who overrode these classes must move to .gl-pill-search, .gl-pill-search__input, .gl-pill-search__submit, .gl-pill-search__clear.

Added ​

  • @geoleaf-plugins/measure v1.0.0: an MIT map measurement plugin (distance, area, circle, georeferenced DOM tooltip annotations, GPS track) — published on npmjs.org. Public facade: GeoLeaf.Measure.activate(), deactivate(), clearAll(), exportGeoJSON(), importGeoJSON(), getPrintableAnnotations(), setMenuPosition(), getMenuHeight(). Configurable through measureConfig in the GeoLeaf profile.
  • @geoleaf-plugins/print v1.1.0: a conditional "Annotations" checkbox in the print modal — visible only if @geoleaf-plugins/measure is loaded; tooltip annotations are composed into the canvas export at their geographic coordinates through GeoLeaf.Measure.getPrintableAnnotations(). New field printConfig.includeAnnotations (boolean, default true). i18n in 6 languages.
  • CSS variables --gl-color-tooltip-bg / --gl-color-tooltip-text in geoleaf-theme.css (:root, .gl-theme-light, .gl-theme-dark) — the tooltips of the pill bar and of the plugin-measure sub-menu buttons now follow the current theme instead of hard-coded colours.
  • GeoLeaf.Share (view sharing): a new public facade exposing openShareDialog(), closeShareDialog(), isOpen() and getShareUrl(). It shows an accessible modal with the current permalink (window.location.href), a "Copy" button (navigator.clipboard.writeText plus an execCommand fallback) and a "Show QR code" button that lazy-loads the qrcode-generator library on the first click only (a separate chunk, ~12 KB gzip, no impact on the initial bundle).
  • ui.showShareButton (boolean, default true) in IUIConfig: controls the display of the "Share" buttons (mobile pill bar + desktop tab strip).
  • Mobile "Share" button injected into the pill bar through the registry (mobileIcon) — behaviour consistent with the print button.
  • Desktop "Share" button inserted in the tab strip between the separator and the theme toggle.
  • @geoleaf-plugins/print v1.0.0: an MIT map printing plugin (PDF/JPG export, A4/A3 at 300 DPI, interactive scale × paper format flow, off-screen re-render, 2D canvas composition, inline legend, optional server fallback) — published on npmjs.org.
  • GeoLeaf.I18n.registerDict(namespace, dictsByLang): a core API letting plugins register their own i18n dictionaries.
  • preserveDrawingBuffer auto-detected in maplibre-adapter.ts when the print plugin registers — no manual configuration required.
  • ui.showPrint in UIConfig plus printConfig in GeoLeafConfig: integration of the print plugin's configuration into the GeoLeaf profile.

Fixed ​

  • gl_shown permalink: opening a shared link containing gl_shown=<layerId> for a layer outside the active theme now displays that layer correctly. Restoration was previously a silent no-op because VisibilityManager.setVisibility() ignored layers absent from GeoJSONShared.state.layers. A new restoreShownLayer helper (modules/built-in/permalink/permalink-layers.ts) lazy-loads the layer through ThemeApplierCore._loadLayerFromProfile() before applying the user override.

Breaking Changes (public type rename) ​

  • LeafletLayerLike → LayerLike: the public type LeafletLayerLike has been renamed to LayerLike in @geoleaf/core. This rename is a hard breaking change with no deprecation alias. Migration: replace every import { LeafletLayerLike } from '@geoleaf/core' with import { LayerLike } from '@geoleaf/core'.

Changed ​

  • Internal — cache UI moved out of the core: the CacheSection module (dead code, never rendered in production) and the associated CSS assets (geoleaf-cache.css, cache-modal.css) have been removed from @geoleaf/core. The ui.cache.* / toast.cache.* / aria.cache.* / format.cache.* i18n keys have been removed from the core as well. These elements now live exclusively in @geoleaf-plugins/storage (CSS bundled inline through rollup-plugin-postcss, auto-injected when the plugin loads). No user-facing impact: the ui.showCacheButton flag still works (the plugin reads it directly from cfg.ui) and the cache button still appears when @geoleaf-plugins/storage is loaded. Typing: the showCacheButton field no longer appears explicitly in UIConfig — it is still accepted through the [key: string]: unknown passthrough.
  • Internal export GeoLeaf._LayerManagerCacheSection: removed (a private _-prefixed reference, never publicly documented).

Added ​

  • Multi-format table export (CSV, KML, GPX, Excel): the GeoLeaf table now supports 5 export formats — GeoJSON (existing), CSV, KML, GPX and Excel (.xlsx through SheetJS, lazy-loaded). The interface goes from a single "Export" button to two split-button dropdowns: "Export selection" (active only when there is a selection) and "Export layer" (always active when a layer is loaded). Each dropdown lists the configured formats.

  • Table.exportSelection(format?, options?): an existing method, extended. It now accepts an ExportFormat ('geojson' | 'csv' | 'kml' | 'gpx' | 'excel') and an optional ExportOptions object. Default: 'geojson' (backwards compatible).

  • Table.exportLayer(format?, options?): a new public method. It exports all the features of the active layer (without the maxRowsPerLayer limit) in the requested format. Emits table:exportLayer with { layerId, format, count }.

  • TableConfig in geoleaf.config.json or profile.json: a formalised TypeScript interface with the new keys exportFormats, csvSeparator, csvIncludeGeometry. See the dedicated section in PLUGIN_CONFIGURATION_GUIDE.md.

  • table:exportLayer: a new event emitted on document after a whole-layer export. Payload: { layerId: string, format: ExportFormat, count: number }.

  • UIConfig.showCredentialButton: a new optional boolean field in UIConfig (typing only). It lets the @geoleaf/connector plugin ≥ 1.1.0 read whether the credential button is enabled from the ui.json profile. No core code consumes this field (no-plugin-in-core compliance). From @geoleaf/connector 1.2.1 onwards, this flag alone is enough to mount the button without a prior GeoLeaf.Connector.configure() call (UI-only auto-bootstrap on the plugin side, triggered by geoleaf:profile:loaded / geoleaf:map:ready; read through GeoLeaf.Config.getActiveProfile()).

  • @geoleaf-plugins/realtime-layer — data.realtime.fallbackUrl: a new optional field of the data.realtime schema. A local CDN snapshot served automatically by PollingSource when the primary URL returns a non-2xx HTTP status or fails (network error). The snapshot is emitted once per outage; the source keeps polling the primary and returns to it on its first success. Polling only.

  • @geoleaf-plugins/cog: a new Cloud Optimized GeoTIFF (COG) rendering plugin. Native reading through geotiff@^3.0.5 with automatic overview selection based on the viewport, multi-band rendering (1/3/4 channels, transparent nodata, colorMap LUT), injection as a MapLibre GL JS image source. API: GeoLeaf.COG.addLayer(url, map, opts?), GeoLeaf.COG.removeLayer(map, id), GeoLeaf.COG.getInfo(url, opts?). A separate bundle from the core, published on npmjs.

  • @geoleaf-plugins/flatgeobuf: a new MIT FlatGeobuf loading plugin. Streaming through an async iterator (flatgeobuf v4.4.0), spatial bbox filtering through an R-tree index plus HTTP Range requests, debounced auto-refresh on the viewport. API: GeoLeaf.FlatGeobuf.load(url), loadBbox(url, bbox), loadAsLayer(url, options?), loadBboxAsLayer(url, bbox, options?), loadLayerFromConfig(config) (declarative JSON configuration). A separate bundle from the core (~91 KB raw / 20 KB gzip). First examples in profiles: france-rail/zones_desserte (bbox + auto-refresh), tourism/eco_regions_fgb (local file; size gain −51% versus the source GeoJSON).

  • @geoleaf-plugins/file-import: a new MIT geospatial file import plugin. Supported formats: GPX, KML, KMZ, CSV (lat/lng or WKT), TopoJSON. API: GeoLeaf.FileImport.convert(file), importAsLayer(file, options?), getSupportedFormats(), registerConverter(ext, converter). A separate bundle from the core (~276 KB raw).

  • Geocoding: the 31st named ESM export. A lazy-loaded address search module (_loadModule("geocoding")), API GeoLeaf.Geocoding. Four built-in providers: addok, nominatim, photon, custom URL.

  • geoleaf:geocoding:result: a new event emitted when a geocoding result is selected — payload { label, lat, lng, bounds? }.

  • GeocodingConfig in ui.json or geoleaf.config.json: parameters enabled, provider, position, placeholder, minChars, resultLimit, debounceMs, flyToZoom.

  • 3D terrain: relief support on raster (type: "tile") and vector basemaps (type: "maplibre"). Configuration through basemaps.{id}.terrain (enabled, demUrl, demEncoding, demMaxZoom, exaggeration, default3D, pitch, bearing). Automatic activation without a UI toggle — default3D: true enables terrain when switching to the basemap, false disables it. DEM source validated in production: AWS Terrarium (~30 m).

  • map.maxPitch in profile.json: a configurable camera pitch ceiling. GeoLeaf raises the MapLibre GL JS default limit (60°) to 80°. Default value: 80. Configurable through profile.json > map.maxPitch.

  • Fill-extrusion: support for 3D polygons through the MapLibre GL JS fill-extrusion layer type. Set geometry: "fill-extrusion" in the layer config file, then define fillExtrusionColor, fillExtrusionOpacity, fillExtrusionHeight and fillExtrusionBase in the style file. fillExtrusionHeight accepts a fixed value (metres) or a feature field name (e.g. "hauteur"). Validation is handled by style-validator-extrusion.ts: an error if fillExtrusionHeight is missing, a warning if the field is not found in the properties of the first feature.

  • GeoLeaf.Utils.wktToGeoJSON(wkt): converts a WKT string into a GeoJSON geometry object. Supports the 7 standard types (Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection) in 2D and 3D/Z. Supports the SRID prefix (SRID=4326;…) and the Z/M/ZM qualifiers. Returns null without throwing if the input is invalid.

  • OGC API Features: native support for loading GeoJSON layers from an OGC API Features endpoint. Configure data.ogcApi in the layer definition: url, collectionId, bbox, maxFeatures, limit, autoRefresh, autoRefreshDebounce, headers. Automatic pagination through next links, a maxFeatures guard, cancellation through AbortController, automatic conversion of WKT geometries. With autoRefresh: true: re-fetch on moveend with the current viewport bbox.

  • .topojson and .fgb added to FileValidator validation.

  • Basemap type: "image": a new basemap type for static georeferenced images (the native MapLibre image format). Configuration through basemaps.{id}.imageSource (url, coordinates, opacity). The image is positioned by its 4 corners [lng, lat]; without coordinates, world bounds are used by default.

  • Basemap type: "hillshade": relief shading through a MapLibre hillshade layer. Configuration through basemaps.{id}.hillshade (demUrl, demEncoding, demMaxZoom, shadowColor, highlightColor, accentColor, exaggeration, illuminationDirection, illuminationAnchor). It automatically reuses the terrain-dem DEM source if it is already present with the same URL (compatible with type: "tile" 3D terrain).

  • Basemap type: "wmts": support for OGC WMTS servers through dynamic GetCapabilities resolution. Configuration through basemaps.{id}.wmts (getCapabilitiesUrl, layer, tileMatrixSet, format). Namespace-safe XML parsing, in-memory cache of resolved URLs, cancellation through AbortController.

  • Basemap type: "wms": support for OGC WMS servers (raster streams). Configuration through basemaps.{id}.wms (url, layers, version, crs, format, tileSize, transparent, styles). It builds the URL template with the {bbox-epsg-3857} placeholder, compatible with MapLibre GL JS.

Changed ​

  • GPX extraction out of the core: the GPX→GeoJSON conversion (the DataConverter.convertGpxToGeoJSON() method plus its private helpers) has been removed from @geoleaf/core and migrated to @geoleaf-plugins/file-import. The route pipeline (route-utils.ts::parseGPX()) and normalisation (normalizer.ts::normalizeFromGPX()) are not affected. The DataConverterLike interface is updated (convertGpxToGeoJSON() removed). The single-layer.ts loader no longer has an isGpx branch — a simpler loading flow.

Docs ​

  • Complete geocoding guide: the Configuration Guide (§12) extended with a provider selection guide (Addok = French local authorities, Nominatim = general worldwide use), a comparison table (coverage, quota, latency, attribution), the Nominatim usage policy (1 req/s, automatic User-Agent, automatic Accept-Language), the custom provider schema (fields read by the internal parser), the programmatic API (GeoLeaf.Geocoding.search/selectResult/destroy) and a security note. A new recipe in COOKBOOK §11 (4 variants: minimal Addok, worldwide Nominatim, custom event, search without UI), a new USER_GUIDE §7.6 section (keyboard navigation, flyTo versus fitBounds), and 6 FAQ entries (API key, provider choice, Nominatim 429, area filtering, result event, custom provider). Additional note in API_REFERENCE: silent fallback to Addok when not on HTTPS, destroy() behaviour.

Fixed ​

  • @geoleaf-plugins/realtime-layer — reading data.realtime: RealtimeManager.bootFromProfile() and start() did not find the realtime configuration when it was nested inside the data block of the layer JSON (the canonical profile schema). The plugin only read config.realtime at the root. The lookup now checks config.data.realtime first, then falls back to config.realtime.
  • Flat fill-extrusions on load: fill-extrusion layers displayed flat (height 0) on initial load and after a style change. Cause: the complete style object { id, label, style: {…} } was passed straight to the rendering functions instead of the flat paint — toFillExtrusionPaint() did not find the fillExtrusionHeight and similar keys at the root. Fixed in theme-applier/visibility.ts and vector-tiles.ts.
  • Active style lost after a basemap change: GeoJSON layers (including fill-extrusion ones) systematically reverted to the default style after every basemap switch. Cause: _rebuildGeoJSONLayers() read .defaultStyle on a flat paint, always undefined. Fixed by reading currentStyle.style ?? currentStyle.
  • Spurious line sub-layer on fill-extrusion layers: a line sub-layer was generated on top of the 3D volumes, producing a spurious rendering. Fixed by a geometry !== "fill-extrusion" guard in maplibre-helpers.ts.
  • Vector tile layers invisible after a basemap switch: VT layers were not reloaded after a basemap change (they were skipped in _rebuildGeoJSONLayers because features: []). Fixed by a dedicated isVectorTile === true branch triggering loadVectorTileLayer().

Earlier versions ​

The changelog for versions before 3.0.0 is not carried over here. The release notes for 2.0.0 — the first npm publication, which carried the move from Leaflet to MapLibre GL JS — remain available: Patchnote V2.0.0.

Every breaking change carries its own "Migration" note in the section of the version concerned above.

Released under the MIT License.