Skip to content

Internals — Overview

This section is for contributors working on the shell (app/src/) — the framework code, as opposed to the user surface you configure. If you're building an app, you want Configuration and Widgets instead.

Directory map

app/src/
├── main.tsx                  # entry: applies theme + UI vars, mounts AppShell
├── index.css                 # Tailwind + @theme token vars + global rules
├── core/                     # framework logic (no JSX chrome)
│   ├── types.ts              # the Settings type + all config types
│   ├── store.ts              # the Zustand store
│   ├── hooks.ts              # useMap / useMapReady
│   ├── registry.ts           # widget auto-discovery + resolveWidget
│   ├── widget-types.ts       # WidgetManifest / WidgetPlacement
│   ├── theme.ts              # applyTheme / applyUiVars (tokens → CSS vars), DEFAULT_COLORS
│   ├── themes.ts             # the built-in presets (light, dark, atomic, ocean, wacky)
│   ├── storageKey.ts         # appId-namespaced localStorage keys
│   ├── layout.ts             # resolveLayout / validateLayout
│   ├── configError.ts        # shared boot-time error shape (see Config validation)
│   ├── icons.tsx             # shared chrome glyphs (trash, close, kebab, filter, …)
│   ├── mapState.ts           # camera + basemap + layer snapshot (bookmarks)
│   ├── basemapSwitch.ts      # switchBasemap — keeps globe/terrain across a basemap switch
│   ├── globe.ts / globeAtmosphere.ts  # globe projection + stars/comets/halo
│   ├── terrain.ts            # raster-DEM terrain source
│   ├── infoDismiss.ts        # info-modal "don't show again"
│   ├── useDataLoading.ts / useMediaQuery.ts  # loader-overlay + mobile signals
│   ├── layers/               # the layer pipeline (see Layers & Loaders)
│   ├── filters/              # layer-list filter compile / apply / validate
│   ├── popups/               # popup resolve + handlers (see Popups)
│   ├── legend/               # symbology introspection (see Legend)
│   ├── search/               # geocoder providers + in-layer feature search
│   └── contextMenu/          # right-click menu actions
└── shell/                    # the React chrome
    ├── AppShell.tsx          # the root: desktop/mobile branch + layout regions
    ├── MapView.tsx           # creates the MapLibre map; registers layers/popups
    ├── mapControls.ts        # native MapLibre controls from settings.mapControls
    ├── TopBar / SidePanel / RightPanel / FloatingSlots / MobileShell / MobileSheet …
    ├── PopupHost / PopupContent / ContextMenu / InfoModal / LoadingOverlay
    ├── ErrorBoundary.tsx / BootError.tsx  # boot-time error display (see Config validation)
    ├── WidgetIcon.tsx        # widget launcher icons, tinted by the theme (CSS mask)
    ├── ConfirmDialog.tsx     # shared confirm modal
    └── ToggleSwitch.tsx      # shared on/off switch

The boot lifecycle

  1. main.tsx applies the theme and UI CSS variables synchronously (so the first paint has the right colors — no flash), then imports <AppShell> dynamically and mounts it. The dynamic import is deliberate — see Config validation below.
  2. <AppShell> validates the layout, search, layer-filter, and theme config (a same-side collision, search.geocoder.provider: 'nominatim' under commercialUse, or an unknown theme.preset throws here), reads settings.ui.info to decide whether to auto-open the info modal, and branches on isMobile to render the desktop or mobile shell. The desktop shell lays out the top bar above a row of left slot · map · right slot, with the rail and top-bar panel placed by side.
  3. <MapView> creates the MapLibre map, registers native controls, and on the 'load' event flips isMapReady. A second effect then registers declarative layers and popup handlers.
  4. On style.load (every basemap switch, since setStyle wipes sources), layers + popups re-register. Per-layer teardowns run first so persistent listeners (e.g. FlatGeobuf's moveend) don't stack.

Switch basemaps with switchBasemap(), not map.setStyle()

setStyle() resets globe projection and terrain, since no basemap style declares them. switchBasemap() (core/basemapSwitch.ts) records both first, and MapView restores them on style.load. The Basemap widget and bookmark restore both use it — any new call site must too.

Config validation

Config problems throw at boot rather than rendering something broken or silently wrong. Every one of these throws goes through a shared helper, configError({ where, why, fix }) in core/configError.ts, so the message always has the same shape: the exact location of the problem (a settings.ts path, or a widget file path) → why it's invalid → the fix. Checks live next to the feature they guard, not in one central validator:

Where Throws when
layout.ts — validateLayout side rail + top-bar panel share a side
registry.ts — assertSurfaceSupported a widget's placed on a surface its manifest doesn't declare
registry.ts — getWidgetManifest an unknown widget name is referenced
registry.ts — resolveMobileWidgets a mobileWidgets entry declares no surfaces
registry.ts — widget registration a widget file is missing its manifest export, or two widgets share an id
search/providers.ts — validateSearchConfig search.geocoder.provider is 'nominatim' while commercialUse is true
filters/validate.ts — validateLayerFilters a layer declares filters on an unsupported type, or lists a column twice
theme.ts — validateThemeConfig theme.preset names a preset that doesn't exist

These throws fire at two different moments. validateLayout, validateSearchConfig, validateLayerFilters, validateThemeConfig, and the widget-registration checks run at module-evaluation time, before React renders anything — that's why main.tsx imports <AppShell> dynamically, inside a try/catch: a static import's module-eval throw happens before any of main.tsx's own code, including a try/catch, would even run. assertSurfaceSupported, getWidgetManifest, and resolveMobileWidgets run during React's render (inside resolveWidget), so <AppShell> is also wrapped in an ErrorBoundary. Both paths render the same BootError fallback — the full message in dev, a generic one otherwise.

Only the first problem is reported

Each check throws immediately, so a second config problem isn't surfaced until the first is fixed and the page reloads.

The store

State lives in one Zustand store (core/store.ts) read via useStore. Key slices:

Slice Purpose
map / isMapReady The MapLibre instance + readiness
isMobile Drives the desktop/mobile branch (single source of truth)
layerMetadata Per-layer runtime state (visibility, opacity, labels) — the source of truth the layer list/legend read, and what survives basemap switches
layerBoundsGetters Per-layer Zoom to Extent resolvers, set by registerLayers
layerFilters / layerFilterHandlers Per-layer filter state (session-only, re-applied on every re-registration) and each loader's filter mechanism
openWidgetId / activeFloatingWidgetId / mobileSheetWidgetId Which widget UI is open on each surface
currentBasemapId The active basemap
collapsedGroups / seenGroups Layer-list group state (kept in the store so it survives widget unmount)
selectedFeatures / selectedFeatureIndex / selectedFeatureTrigger Popup state
popupPosition User-dragged desktop popup position (null = default anchor)
coordinateCaptureArmed Suppresses popups while the coordinates widget is capturing a click

A recurring pattern: runtime state is the store's job; settings.ts is the source of truth only on cold boot. That's why toggling a layer survives a basemap switch — re-registration reads the store's metadata, not the settings defaults.

The merge convention

Config resolves kit default ← global setting ← per-item override everywhere (popups, widget headers, layer-list actions, labels, …). Almost every field is optional; an omitted key falls back rather than erroring. When adding a setting, follow this — give it a default and merge it, never require it.

The subsystem pages go deeper: