Internals: Overview¶
This section is for contributors working on the shell (app/src/), the framework code behind the user files. 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
├── user-modules.d.ts # types for the @user/… imports, so the shell typechecks with no project
├── index.css # Tailwind + @theme token vars + global rules
├── core/ # framework logic (no JSX chrome)
│ ├── schema/ # Zod schemas for every setting: descriptions, defaults, docs links (the source of the config types)
│ ├── types.ts # the config types (z.input of the schemas, same names as ever) + runtime types
│ ├── store.ts # the Zustand store
│ ├── hooks.ts # useMap / useMapReady
│ ├── registry.ts # widget discovery (virtual:widget-configs) + resolveWidget
│ ├── placedWidgets.ts # placedWidgetIds: the widget ids a project's settings place
│ ├── widget-types.ts # WidgetManifest / WidgetPlacement
│ ├── theme.ts # applyTheme (color tokens → CSS vars), DEFAULT_COLORS
│ ├── uiVars.ts # applyUiVars (settings.ui icon and text sizes, cursor, top-bar shadow → CSS vars)
│ ├── previewCamera.ts # dev-only: keeps the camera across the editor's Preview Pane reloads (?conness-preview)
│ ├── themes.ts # the built-in presets (light, dark, atomic, ocean, wacky)
│ ├── storageKey.ts # appId-namespaced localStorage keys + loadStored / saveStored
│ ├── layout.ts # resolveLayout / validateLayout
│ ├── configError.ts # shared boot-time error shape (see Config validation)
│ ├── mapState.ts # captureMapState / applyMapState: the bookmark snapshot (camera, basemap, globe, terrain, layers, filters)
│ ├── basemapSwitch.ts # switchBasemap, selectBasemap, withoutBasemapProjection (boot style)
│ ├── basemapLayers.ts # the switch itself: swaps only the basemap's layers and sources
│ ├── 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
│ ├── download.ts # downloadFile / downloadGeoJSON, EXPORT_FORMATS (draw + layer list)
│ ├── writeKML.ts / writeGeoParquet.ts / writeCSV.ts # export writers (GeoParquet loads hyparquet-writer on demand)
│ ├── duckdb/ # engine.ts (shared DuckDB-WASM), readGeo.ts, tileTable.ts + tileProtocol.ts (mvt tiles)
│ ├── palette.ts # shared color palette (Draw, PMTiles automatic colors)
│ ├── mapboxDraw.ts / persistentOverlay.ts # map helpers shared by Draw + Measure, incl. drawEvents (any widget can use them)
│ ├── layers/ # the layer pipeline (see Layers & Loaders)
│ ├── filters/ # layer-list filter compile / apply / validate (+ compile.test.ts, npm run test)
│ ├── popups/ # popup resolve + handlers (see Popups)
│ ├── legend/ # symbology introspection (see Legend)
│ ├── search/ # geocoder providers + in-layer feature search
│ └── contextMenu/ # right-click menu actions
├── ui/ # the UI kit widgets and chrome share (imported as @/ui/…)
│ ├── icons.tsx # in-widget glyphs (trash, close, chevron, eye, …)
│ ├── ConfirmDialog / ToggleSwitch / InfoSection
│ ├── IconButton / ActionButton / EmptyState / ToolButtonGrid / ItemRow / RenameInput
│ ├── ChoiceDialog / DialogFrame / Spinner
│ └── useCopyToClipboard.ts / useSavedItems.ts
└── shell/ # the React chrome
├── AppShell.tsx # the root: one tree for desktop + mobile, so the map never remounts
├── MapView.tsx # creates the MapLibre map (setWorkerUrl for MapLibre 6's worker); registers layers/popups
├── mapControls.ts # native MapLibre controls from settings.mapControls
├── TopBar / MobileTopBar / SidePanel / RightPanel / FloatingSlots / MobileSheet …
├── PopupHost / PopupContent / ContextMenu / InfoModal / LoadingOverlay
├── ErrorBoundary.tsx / BootError.tsx # boot-time error display (see Config validation)
├── WidgetBoundary.tsx # keeps one widget's render error inside its own pane
└── WidgetIcon.tsx # widget launcher icons, tinted by the theme (CSS mask)
The widgets themselves live outside src/, in app/widgets/, one folder each; app/widgets/WIDGETS.md describes their layout.
Projects and commands¶
The user files are per project, in app/projects/<project>/; the starter npm run new copies is app/templates/basic/. The npm commands that work on a project go through app/scripts/:
| File | Role |
|---|---|
run.mjs |
npm run dev / build / preview [project]: picks the project (the first plain word; flags like --host pass through), sets CONNESS_PROJECT, runs the step. build runs tsc -b, vite build, then build-report.mjs and check-keys.mjs |
project.mjs |
resolveProject: the named project, else the only one; several or none stop with how to fix it. A project is a folder with a settings.json; folders starting with _ are never projects |
new-project.mjs |
npm run new "Name": copies the template, sets branding.title / appId in settings.json, -2 on a clash |
project-config-plugin.ts |
loadProject: reads the project's five JSON files (or overrides in their place, the editor's unsaved edits), validates them against core/schema/, and assembles settings; api.setDraft serves a draft instead of the files until it's cleared; serves it and symbologies as the virtual modules behind @user/settings and @user/symbology; refreshes app/schema/*.schema.json at dev and build start (see Config validation) |
formatJson.ts |
the JSON style project files are written in: a container on one line when it fits, else one entry per line. npm run new uses it |
widget-configs-plugin.ts |
the virtual:widget-configs module behind the widget registry (see Widget Registry) |
projects-api-plugin.ts |
the editor's /api/projects (list, read, save, rename, trash) and /api/widgets endpoints behind npm start |
app-servers.ts |
the editor runs the app's own Vite servers: preview serves the unsaved edits, saved only the files on disk |
vite.editor.config.ts is the editor's own config (npm start, dev-only, never built).
vite.config.ts resolves the same project: it points publicDir (logo, favicon) and build.outDir (dist/<project>/) at it, and sets the @user aliases in order: @user/settings and @user/symbology go to project-config-plugin.ts's virtual modules, and everything else under @user/ (popups.tsx, info.md?raw, layers/) to the project's folder. Local layer files resolve through import.meta.glob('@user/layers/**') in layers/resolveLayerUrl.ts, whose /projects/<id>/layers/… keys are matched against the ./layers/… paths in layers.json. The shell never names a project; src/user-modules.d.ts types what it imports (@user/settings, @user/popups, @user/symbology).
The config types come from the Zod schemas in core/schema/: types.ts exports them as z.input<…> under the names they always had, so engine code imports LayerConfig, Settings and the rest as before. The imports are type-only, so Zod never reaches a built app. The schemas record each setting's default for hover and the editor, but validation passes the JSON through as written; the engine applies its defaults itself (the merge convention). A new setting goes in the schema first: .optional(), with .meta({ description, default, see }) (defaultText for a default in words), and the engine reads it with its own fallback.
The boot lifecycle¶
main.tsxapplies the theme and UI CSS variables before the first render, so the first paint already has the right colors. It then imports<AppShell>dynamically and mounts it; Config validation explains why the import is dynamic.<AppShell>validates the layout, search, layer filter and theme settings. A same-side layout collision,search.geocoder.providerset to"nominatim"withcommercialUseon, an unknowntheme.preset, or a CSS color (hex,rgb()…) intheme.colorsthrows here. It then readssettings.ui.infoto decide whether to open the info modal, and renders the layout forisMobile. Both layouts share one tree: a top bar above a row of left slot, map and right slot. On desktop the side rail and top-bar panel fill the slots by side, and the map area holdsPopupHostandContextMenu. On mobile the top bar isMobileTopBar, the slots are empty, and the map area holdsMobileSheet. The map area and itsMapViewstay in the same place either way, so crossing the breakpoint swaps the chrome without rebuilding the map.<MapView>creates the MapLibre map without a style, then appliessettings.map.stylewithsetStyle(style, withoutBasemapProjection)(see the warning below). It adds the native controls and, on the'load'event, records the boot basemap's layers and sources (recordBootBasemap) and setsisMapReady. A second effect then registers layers and popup handlers, once.- A basemap switch (
selectBasemap: highlights the new basemap at once, and gives the highlight back to the one on the map if the style fails) swaps only the basemap's own layers and sources (see the warning below). Data layers, popup handlers, globe, terrain and widget layers stay as they are, andstyle.loaddoesn't fire, so nothing registers again.
Switch basemaps with switchBasemap(), not map.setStyle()
setStyle() would remove every data layer, because the new basemap's style doesn't list them. switchBasemap() (core/basemapSwitch.ts) calls swapBasemap (core/basemapLayers.ts), which keeps the style that's loaded and changes only the basemap part of it:
- Fetch the new style JSON. If that fails, the current basemap stays and the console says why.
- Remove the previous basemap's layers and sources (the ones recorded at boot, or added by the last switch).
- Add the new ones with a
basemap:prefix on every id, so they can't collide with yours, below the lowest data layer. - Swap fonts and icons with
setGlyphsandsetSprite. A style withoutglyphs(a raster basemap such as Esri satellite) keeps the previous fonts, so your labels still draw.
The style's own projection, sky, terrain and light are left out: only settings.map.globe, the globe control and the terrain control set those. The boot style gets the same treatment from withoutBasemapProjection, a transformStyle that removes projection and sky (VersaTiles declares a globe projection, which would otherwise turn the globe on by itself). switchBasemap() resolves once the map is idle with the new basemap drawn, or at once if the switch failed or a later click replaced it. The Basemap widget and bookmark restore both use it, and any new caller must too. switchBasemap(map, null) removes the basemap group and adds nothing, keeping glyphs and sprite; the store and bookmarks record that as NO_BASEMAP_ID ('none').
Config validation¶
Config problems are caught at two points.
When the project's files are read. loadProject (scripts/project-config-plugin.ts) parses the five JSON files and checks each against its schema before any code that uses them runs: at npm run dev start, on every save in dev, and before a build. The objects are strict, so an unknown key is an error, apart from the free-form spots (raw paint and layout, inline basemap styles, popup fields, layerGroups names). Every issue in every file is collected into one configError, one line per issue as <file>.json → <path>: <message>. In dev it's logged at startup and thrown from the virtual module's load, so Vite's overlay shows it and a fixed save recovers; in a build it stops the build. Invalid JSON names the file and the parser's position.
At boot, for what a schema can't see: checks across settings, or against what the app has (widget manifests, theme presets, popup templates). Most of these throw instead of rendering something broken or quietly wrong. Every throw goes through one helper, configError({ where, why, fix }) in core/configError.ts, so messages have the same shape: where the problem is (a settings path or a widget file), why it's invalid, and how to fix it. Each check lives next to the feature it guards; there's no central validator.
| Where | Throws when |
|---|---|
layout.ts: validateLayout |
the top-bar panel opens on the side rail's side while both have widgets |
registry.ts: assertSurfaceSupported |
a widget is placed on a surface its manifest doesn't list |
registry.ts: getWidgetManifest |
a widget name doesn't exist |
registry.ts: resolveMobileWidgets |
a mobileWidgets entry has no surfaces |
registry.ts: widget registration |
a widget file has no manifest export, or its id doesn't match its folder name |
widget-configs-plugin.ts (build only) |
a placed widget name has no widget folder; the build stops |
search/providers.ts: validateSearchConfig |
search.geocoder.provider is 'nominatim' while commercialUse is true |
filters/validate.ts: validateLayerFilters |
a layer has filters on an unsupported type, or lists a column twice |
theme.ts: validateThemeConfig |
a theme.colors value is a CSS color (hex, rgb()…), or theme.preset names a preset that doesn't exist (the schema already catches that when the files are read; this guards the list in themes.ts) |
These checks run at two different times. validateLayout, validateSearchConfig, validateLayerFilters, validateThemeConfig and the widget registration checks run when their modules load, before React renders anything. A throw during a static import would happen before any code in main.tsx runs, including its own try/catch, so main.tsx imports <AppShell> dynamically inside a try/catch. assertSurfaceSupported, getWidgetManifest and resolveMobileWidgets run during React's render (inside resolveWidget), so <AppShell> is also wrapped in an ErrorBoundary. Both paths show the same BootError screen, with the full message in dev and a generic one otherwise. A widget's own render errors don't get that far: each place a widget mounts wraps it in WidgetBoundary, which shows "This widget couldn't load" in that pane only (an ErrorBoundary with a fallback).
Layer and popup config errors are the exception: they're thrown while MapView registers layers and popups, inside its own try/catch, so they only reach the browser console. A duplicate layer id, a top-level opacity or an unknown symbology ref logs Failed to register layers and no layers load. A mistake inside one layer (a PMTiles source layer listed twice, a tile template without sourceLayers, raw paint without a geometry) logs Skipping layer "<id>" and only that layer is missing. An unknown popup displayType logs Failed to register popups and every popup stays off.
Every message the kit writes to the browser console starts with [conness-gis], so they're easy to filter.
Boot checks report only the first problem
Unlike the schema check, which lists everything, each boot check throws right away, so a second problem only shows up after the first is fixed and the page reloads.
The store¶
State lives in one Zustand store (core/store.ts), read with useStore. The main slices:
| Slice | Purpose |
|---|---|
map / isMapReady |
The MapLibre instance, and whether it's ready |
isMobile |
Whether the mobile or desktop layout renders: below 500 px wide (MOBILE_BREAKPOINT_PX in useMediaQuery.ts). The only place this is decided |
layerMetadata |
Per-layer runtime state (visibility, opacity multiplier, labels). The layer list and legend read it. PMTiles parent rows and their children are linked through isParent / childIds / parentId |
layerBoundsGetters |
Per-layer Zoom to Extent functions, set by registerLayers |
layerFeatureGetters |
Per-layer Export Data feature getters, set by registerLayers (geojson and geoparquet only) |
pendingLayerData / loadingStatus |
Layers whose data is still arriving (DuckDB, deferred loads, FlatGeobuf, PMTiles metadata; drives the layer list's spinner), and DuckDB's current startup step; both feed the loading overlay |
layerFilters / layerFilterHandlers |
Per-layer filter state (cleared on reload) and each loader's filter mechanism |
openWidgetId / activeFloatingWidgetId / mobileSheetWidgetId |
Which widget is open on each surface |
currentBasemapId |
The current basemap |
collapsedGroups / seenGroups |
Layer list group state, kept here so it survives the widget closing |
expandedParents |
Which PMTiles parent rows are open in the layer list (they start collapsed unless the layer sets collapsed: false) |
popupLayerOverrides |
Popup targets a loader decided at load time, keyed by settings layer id (PMTiles: one per drawn source layer); see Popups |
selectedFeatures / selectedFeatureIndex / selectedFeatureTrigger |
Popup state |
popupPosition / popupSize |
Where the user dragged the desktop popup, and the size they resized it to (null = configured position or size) |
mapClickOwners |
Widgets currently using map clicks (useMapClickLock); popups stay off while the list isn't empty |
Runtime state belongs to the store; the project's JSON only decides the starting state.
The merge convention¶
Settings resolve as kit default ← global setting ← per-item override everywhere: popups, widget headers, layer list actions, labels and so on. Almost every field is optional, and a missing key falls back instead of erroring. New settings must follow this: give them a default and merge them, never make them required.
The subsystem pages go further:
- Layers & Loaders: the loader dispatch and the shared sublayer model
- Popups: the single click and hover handler, and how popup settings resolve
- Legend: reading symbology into swatches
- Theme & Storage: tokens and UI sizes to CSS variables, and per-app storage keys
- Widget Registry: discovery, build-time widget selection and
resolveWidget - Draw & Measure: the controllers behind the two drawing widgets